Skip to content

fix: enhance tokenizer and add comprehensive tool support #42

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
{
"name": "copilot-api",
"version": "0.3.1",
"name": "@nghyane/copilot-api",
"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",
"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 <erickchristian48@gmail.com>",
"author": "Nghyane <hoangvananhnghia@gmail.com>",
"type": "module",
"bin": {
"copilot-api": "./dist/main.js"
Expand Down
4 changes: 2 additions & 2 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -13,7 +14,6 @@ interface RunAuthOptions {
export async function runAuth(options: RunAuthOptions): Promise<void> {
if (options.verbose) {
consola.level = 5
consola.info("Verbose logging enabled")
}

await ensurePaths()
Expand Down
2 changes: 1 addition & 1 deletion src/lib/api-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Authorization: `Bearer ${state.copilotToken}`,
"content-type": standardHeaders()["content-type"],
Expand Down
43 changes: 43 additions & 0 deletions src/lib/format-detector.ts
Original file line number Diff line number Diff line change
@@ -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'
}
}
9 changes: 8 additions & 1 deletion src/lib/forward-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/http-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,7 @@ export class HTTPError extends Error {
constructor(message: string, response: Response) {
super(message)
this.response = response

console.error(message, response)
}
}
9 changes: 9 additions & 0 deletions src/lib/logger.ts
Original file line number Diff line number Diff line change
@@ -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),
}
39 changes: 39 additions & 0 deletions src/lib/model-utils.ts
Original file line number Diff line number Diff line change
@@ -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
}
38 changes: 32 additions & 6 deletions src/lib/models.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,40 @@
import consola from "consola"

import { getModels } from "~/services/copilot/get-models"

import { state } from "./state"

/**
* 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 {
// Handle disguised Claude models - map back to real Claude models
if (modelName === "gpt-4.1") {
return "claude-sonnet-4"
}

if (modelName.startsWith("gpt-4.1-")) {
return "gpt-4.1"
}


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<void> {
const models = await getModels()
state.models = models

consola.info(
`Available models: \n${models.data.map((model) => `- ${model.id}`).join("\n")}`,
)
}
15 changes: 15 additions & 0 deletions src/lib/streaming-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { events } from "fetch-event-stream"

/**
* 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<any> {
const eventStream = events(response)

for await (const event of eventStream) {
yield event
}
}
10 changes: 3 additions & 7 deletions src/lib/token.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
96 changes: 90 additions & 6 deletions src/lib/tokenizer.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,96 @@
import { countTokens } from "gpt-tokenizer/model/gpt-4o"

import type { Message } from "~/services/copilot/create-chat-completions"
import type { MessageRole } from "~/services/copilot/create-chat-completions"

export const getTokenCount = (messages: Array<Message>) => {
const input = messages.filter(
(m) => m.role !== "assistant" && typeof m.content === "string",
)
const output = messages.filter((m) => m.role === "assistant")
// Convert Message to gpt-tokenizer compatible format
interface ChatMessage {
role: "user" | "assistant" | "system"
content: string
}

// Generic message type for tokenizer
interface TokenizerMessage {
role: MessageRole
content: string | Array<any> | null
tool_calls?: Array<any>
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,
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: any) => {
return `Function call: ${toolCall.function?.name}(${toolCall.function?.arguments})`
})
.join(" ")

return {
role: role,
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: any) => {
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,
content: textContent,
}
}

return null
}

export const getTokenCount = (messages: Array<TokenizerMessage>) => {
// 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)
Expand Down
4 changes: 0 additions & 4 deletions src/lib/vscode-version.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import consola from "consola"

import { getVSCodeVersion } from "~/services/get-vscode-version"

import { state } from "./state"

export const cacheVSCodeVersion = async () => {
const response = await getVSCodeVersion()
state.vsCodeVersion = response

consola.info(`Using VSCode version: ${response}`)
}
Loading