copilot-router 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +241 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +32 -0
- package/dist/lib/api-config.d.ts +15 -0
- package/dist/lib/api-config.js +30 -0
- package/dist/lib/database.d.ts +60 -0
- package/dist/lib/database.js +228 -0
- package/dist/lib/error.d.ts +11 -0
- package/dist/lib/error.js +34 -0
- package/dist/lib/state.d.ts +9 -0
- package/dist/lib/state.js +3 -0
- package/dist/lib/token-manager.d.ts +95 -0
- package/dist/lib/token-manager.js +241 -0
- package/dist/lib/utils.d.ts +8 -0
- package/dist/lib/utils.js +10 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +97 -0
- package/dist/routes/anthropic/routes.d.ts +2 -0
- package/dist/routes/anthropic/routes.js +155 -0
- package/dist/routes/anthropic/stream-translation.d.ts +3 -0
- package/dist/routes/anthropic/stream-translation.js +136 -0
- package/dist/routes/anthropic/translation.d.ts +4 -0
- package/dist/routes/anthropic/translation.js +241 -0
- package/dist/routes/anthropic/types.d.ts +165 -0
- package/dist/routes/anthropic/types.js +2 -0
- package/dist/routes/anthropic/utils.d.ts +2 -0
- package/dist/routes/anthropic/utils.js +12 -0
- package/dist/routes/auth/routes.d.ts +2 -0
- package/dist/routes/auth/routes.js +158 -0
- package/dist/routes/gemini/routes.d.ts +2 -0
- package/dist/routes/gemini/routes.js +163 -0
- package/dist/routes/gemini/translation.d.ts +5 -0
- package/dist/routes/gemini/translation.js +215 -0
- package/dist/routes/gemini/types.d.ts +63 -0
- package/dist/routes/gemini/types.js +2 -0
- package/dist/routes/openai/routes.d.ts +2 -0
- package/dist/routes/openai/routes.js +215 -0
- package/dist/routes/utility/routes.d.ts +2 -0
- package/dist/routes/utility/routes.js +28 -0
- package/dist/services/copilot/create-chat-completions.d.ts +130 -0
- package/dist/services/copilot/create-chat-completions.js +32 -0
- package/dist/services/copilot/create-embeddings.d.ts +20 -0
- package/dist/services/copilot/create-embeddings.js +19 -0
- package/dist/services/copilot/get-models.d.ts +51 -0
- package/dist/services/copilot/get-models.js +45 -0
- package/dist/services/github/get-device-code.d.ts +11 -0
- package/dist/services/github/get-device-code.js +21 -0
- package/dist/services/github/get-user.d.ts +11 -0
- package/dist/services/github/get-user.js +17 -0
- package/dist/services/github/poll-access-token.d.ts +13 -0
- package/dist/services/github/poll-access-token.js +56 -0
- package/package.json +56 -0
- package/public/index.html +419 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ModelsResponse } from "../services/copilot/get-models.js";
|
|
2
|
+
/**
|
|
3
|
+
* Token entry with runtime state
|
|
4
|
+
*/
|
|
5
|
+
export interface TokenEntry {
|
|
6
|
+
id: number;
|
|
7
|
+
githubToken: string;
|
|
8
|
+
username: string | null;
|
|
9
|
+
copilotToken: string | null;
|
|
10
|
+
copilotTokenExpiresAt: Date | null;
|
|
11
|
+
accountType: string;
|
|
12
|
+
isActive: boolean;
|
|
13
|
+
models?: ModelsResponse;
|
|
14
|
+
lastUsed?: Date;
|
|
15
|
+
requestCount: number;
|
|
16
|
+
errorCount: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Token Manager - Manages multiple GitHub tokens with load balancing
|
|
20
|
+
*/
|
|
21
|
+
declare class TokenManager {
|
|
22
|
+
private tokens;
|
|
23
|
+
private roundRobinIndex;
|
|
24
|
+
private nextMemoryId;
|
|
25
|
+
/**
|
|
26
|
+
* Get all token entries
|
|
27
|
+
*/
|
|
28
|
+
getAllTokenEntries(): TokenEntry[];
|
|
29
|
+
/**
|
|
30
|
+
* Get active token entries only
|
|
31
|
+
*/
|
|
32
|
+
getActiveTokenEntries(): TokenEntry[];
|
|
33
|
+
/**
|
|
34
|
+
* Get a random active token entry for load balancing
|
|
35
|
+
*/
|
|
36
|
+
getRandomTokenEntry(): TokenEntry | null;
|
|
37
|
+
/**
|
|
38
|
+
* Get next token using round-robin for load balancing
|
|
39
|
+
*/
|
|
40
|
+
getNextTokenEntry(): TokenEntry | null;
|
|
41
|
+
/**
|
|
42
|
+
* Get a specific token entry by ID
|
|
43
|
+
*/
|
|
44
|
+
getTokenEntryById(id: number): TokenEntry | undefined;
|
|
45
|
+
/**
|
|
46
|
+
* Load tokens from database (only if database is connected)
|
|
47
|
+
* If database is not connected, try to load from local gh auth
|
|
48
|
+
*/
|
|
49
|
+
loadFromDatabase(): Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Load token from local gh auth CLI
|
|
52
|
+
* This is used when database is not configured
|
|
53
|
+
*/
|
|
54
|
+
loadFromLocalGhAuth(): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Add a new token (from VSCode login)
|
|
57
|
+
*/
|
|
58
|
+
addToken(githubToken: string, accountType?: string): Promise<TokenEntry>;
|
|
59
|
+
/**
|
|
60
|
+
* Remove a token
|
|
61
|
+
*/
|
|
62
|
+
removeToken(id: number): Promise<boolean>;
|
|
63
|
+
/**
|
|
64
|
+
* Remove all tokens (for cleanup)
|
|
65
|
+
*/
|
|
66
|
+
removeAllTokens(): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Report an error for a token (for tracking)
|
|
69
|
+
*/
|
|
70
|
+
reportError(id: number): void;
|
|
71
|
+
/**
|
|
72
|
+
* Get token count
|
|
73
|
+
*/
|
|
74
|
+
getTokenCount(): {
|
|
75
|
+
total: number;
|
|
76
|
+
active: number;
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Get statistics for all tokens
|
|
80
|
+
*/
|
|
81
|
+
getStatistics(): TokenStatistics[];
|
|
82
|
+
}
|
|
83
|
+
export interface TokenStatistics {
|
|
84
|
+
id: number;
|
|
85
|
+
username: string | null;
|
|
86
|
+
accountType: string;
|
|
87
|
+
isActive: boolean;
|
|
88
|
+
hasValidCopilotToken: boolean;
|
|
89
|
+
copilotTokenExpiresAt: Date | null;
|
|
90
|
+
requestCount: number;
|
|
91
|
+
errorCount: number;
|
|
92
|
+
lastUsed?: Date;
|
|
93
|
+
}
|
|
94
|
+
export declare const tokenManager: TokenManager;
|
|
95
|
+
export {};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import consola from "consola";
|
|
2
|
+
import { exec } from "child_process";
|
|
3
|
+
import { promisify } from "util";
|
|
4
|
+
import { getAllTokens, saveToken, updateGithubToken, deactivateToken, isDatabaseConnected, } from "./database.js";
|
|
5
|
+
import { getGitHubUserForToken } from "../services/github/get-user.js";
|
|
6
|
+
const execAsync = promisify(exec);
|
|
7
|
+
/**
|
|
8
|
+
* Get the local GitHub auth token from gh CLI
|
|
9
|
+
* Returns null if gh is not installed or not authenticated
|
|
10
|
+
*/
|
|
11
|
+
async function getLocalGhAuthToken() {
|
|
12
|
+
try {
|
|
13
|
+
const { stdout } = await execAsync("gh auth token");
|
|
14
|
+
const token = stdout.trim();
|
|
15
|
+
if (token && token.startsWith("gho_")) {
|
|
16
|
+
consola.debug(`Found local gh auth token: ${token.substring(0, 15)}...`);
|
|
17
|
+
return token;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
consola.debug("Failed to get local gh auth token:", error instanceof Error ? error.message : error);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Token Manager - Manages multiple GitHub tokens with load balancing
|
|
28
|
+
*/
|
|
29
|
+
class TokenManager {
|
|
30
|
+
tokens = new Map();
|
|
31
|
+
roundRobinIndex = 0;
|
|
32
|
+
nextMemoryId = 1; // For generating IDs in memory-only mode
|
|
33
|
+
/**
|
|
34
|
+
* Get all token entries
|
|
35
|
+
*/
|
|
36
|
+
getAllTokenEntries() {
|
|
37
|
+
return Array.from(this.tokens.values());
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Get active token entries only
|
|
41
|
+
*/
|
|
42
|
+
getActiveTokenEntries() {
|
|
43
|
+
return this.getAllTokenEntries().filter(t => t.isActive);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Get a random active token entry for load balancing
|
|
47
|
+
*/
|
|
48
|
+
getRandomTokenEntry() {
|
|
49
|
+
const activeTokens = this.getActiveTokenEntries();
|
|
50
|
+
if (activeTokens.length === 0)
|
|
51
|
+
return null;
|
|
52
|
+
const randomIndex = Math.floor(Math.random() * activeTokens.length);
|
|
53
|
+
const token = activeTokens[randomIndex];
|
|
54
|
+
token.lastUsed = new Date();
|
|
55
|
+
token.requestCount++;
|
|
56
|
+
return token;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Get next token using round-robin for load balancing
|
|
60
|
+
*/
|
|
61
|
+
getNextTokenEntry() {
|
|
62
|
+
const activeTokens = this.getActiveTokenEntries();
|
|
63
|
+
if (activeTokens.length === 0)
|
|
64
|
+
return null;
|
|
65
|
+
this.roundRobinIndex = this.roundRobinIndex % activeTokens.length;
|
|
66
|
+
const token = activeTokens[this.roundRobinIndex];
|
|
67
|
+
this.roundRobinIndex++;
|
|
68
|
+
token.lastUsed = new Date();
|
|
69
|
+
token.requestCount++;
|
|
70
|
+
return token;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Get a specific token entry by ID
|
|
74
|
+
*/
|
|
75
|
+
getTokenEntryById(id) {
|
|
76
|
+
return this.tokens.get(id);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Load tokens from database (only if database is connected)
|
|
80
|
+
* If database is not connected, try to load from local gh auth
|
|
81
|
+
*/
|
|
82
|
+
async loadFromDatabase() {
|
|
83
|
+
if (!isDatabaseConnected()) {
|
|
84
|
+
consola.info("Database not connected, trying to load local gh auth token...");
|
|
85
|
+
await this.loadFromLocalGhAuth();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const dbTokens = await getAllTokens();
|
|
89
|
+
for (const record of dbTokens) {
|
|
90
|
+
const entry = {
|
|
91
|
+
id: record.Id,
|
|
92
|
+
githubToken: record.Token,
|
|
93
|
+
username: record.UserName,
|
|
94
|
+
copilotToken: null,
|
|
95
|
+
copilotTokenExpiresAt: null,
|
|
96
|
+
accountType: record.AccountType,
|
|
97
|
+
isActive: record.IsActive,
|
|
98
|
+
requestCount: 0,
|
|
99
|
+
errorCount: 0,
|
|
100
|
+
};
|
|
101
|
+
this.tokens.set(record.Id, entry);
|
|
102
|
+
// Update nextMemoryId to avoid conflicts
|
|
103
|
+
if (record.Id >= this.nextMemoryId) {
|
|
104
|
+
this.nextMemoryId = record.Id + 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
consola.info(`Loaded ${this.tokens.size} tokens from database`);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Load token from local gh auth CLI
|
|
111
|
+
* This is used when database is not configured
|
|
112
|
+
*/
|
|
113
|
+
async loadFromLocalGhAuth() {
|
|
114
|
+
const ghToken = await getLocalGhAuthToken();
|
|
115
|
+
if (!ghToken) {
|
|
116
|
+
consola.info("No local gh auth token found. Use /auth/login to add tokens or run 'gh auth login'.");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
await this.addToken(ghToken, "individual");
|
|
121
|
+
consola.success("Loaded token from local gh auth");
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
consola.warn("Failed to add local gh auth token:", error instanceof Error ? error.message : error);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Add a new token (from VSCode login)
|
|
129
|
+
*/
|
|
130
|
+
async addToken(githubToken, accountType = "individual") {
|
|
131
|
+
// Get user info first to validate token
|
|
132
|
+
consola.debug(`addToken: Validating GitHub token: ${githubToken.substring(0, 15)}...`);
|
|
133
|
+
const user = await getGitHubUserForToken(githubToken);
|
|
134
|
+
const username = user.login;
|
|
135
|
+
consola.debug(`addToken: Token belongs to user: ${username}`);
|
|
136
|
+
// Check if token for this user already exists
|
|
137
|
+
const existingEntry = Array.from(this.tokens.values()).find(t => t.username === username);
|
|
138
|
+
if (existingEntry) {
|
|
139
|
+
// Update existing entry with new token
|
|
140
|
+
consola.debug(`addToken: Updating existing entry for ${username}, old token: ${existingEntry.githubToken.substring(0, 15)}...`);
|
|
141
|
+
existingEntry.githubToken = githubToken;
|
|
142
|
+
existingEntry.isActive = true;
|
|
143
|
+
existingEntry.errorCount = 0;
|
|
144
|
+
// Update in database if connected
|
|
145
|
+
if (isDatabaseConnected()) {
|
|
146
|
+
await updateGithubToken(existingEntry.id, githubToken);
|
|
147
|
+
}
|
|
148
|
+
consola.info(`Updated token for existing user: ${username}`);
|
|
149
|
+
return existingEntry;
|
|
150
|
+
}
|
|
151
|
+
// Generate ID - from database or memory
|
|
152
|
+
let id;
|
|
153
|
+
if (isDatabaseConnected()) {
|
|
154
|
+
id = await saveToken(githubToken, username, accountType);
|
|
155
|
+
consola.debug(`addToken: Saved new token with ID ${id}`);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
id = this.nextMemoryId++;
|
|
159
|
+
consola.debug(`addToken: Generated memory ID ${id}`);
|
|
160
|
+
}
|
|
161
|
+
// Create token entry
|
|
162
|
+
const entry = {
|
|
163
|
+
id,
|
|
164
|
+
githubToken,
|
|
165
|
+
username,
|
|
166
|
+
copilotToken: null,
|
|
167
|
+
copilotTokenExpiresAt: null,
|
|
168
|
+
accountType,
|
|
169
|
+
isActive: true,
|
|
170
|
+
requestCount: 0,
|
|
171
|
+
errorCount: 0,
|
|
172
|
+
};
|
|
173
|
+
this.tokens.set(id, entry);
|
|
174
|
+
consola.success(`Added token for user: ${username}`);
|
|
175
|
+
return entry;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Remove a token
|
|
179
|
+
*/
|
|
180
|
+
async removeToken(id) {
|
|
181
|
+
const entry = this.tokens.get(id);
|
|
182
|
+
if (!entry)
|
|
183
|
+
return false;
|
|
184
|
+
// Deactivate in database if connected
|
|
185
|
+
if (isDatabaseConnected()) {
|
|
186
|
+
await deactivateToken(id);
|
|
187
|
+
}
|
|
188
|
+
this.tokens.delete(id);
|
|
189
|
+
consola.info(`Removed token for user: ${entry.username}`);
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Remove all tokens (for cleanup)
|
|
194
|
+
*/
|
|
195
|
+
async removeAllTokens() {
|
|
196
|
+
if (isDatabaseConnected()) {
|
|
197
|
+
const { deleteAllTokens } = await import("./database.js");
|
|
198
|
+
const count = await deleteAllTokens();
|
|
199
|
+
consola.info(`Removed ${count} tokens from database`);
|
|
200
|
+
}
|
|
201
|
+
this.tokens.clear();
|
|
202
|
+
consola.info("Cleared all tokens from memory");
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Report an error for a token (for tracking)
|
|
206
|
+
*/
|
|
207
|
+
reportError(id) {
|
|
208
|
+
const entry = this.tokens.get(id);
|
|
209
|
+
if (entry) {
|
|
210
|
+
entry.errorCount++;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Get token count
|
|
215
|
+
*/
|
|
216
|
+
getTokenCount() {
|
|
217
|
+
const entries = Array.from(this.tokens.values());
|
|
218
|
+
return {
|
|
219
|
+
total: entries.length,
|
|
220
|
+
active: entries.filter(t => t.isActive && t.copilotToken).length,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Get statistics for all tokens
|
|
225
|
+
*/
|
|
226
|
+
getStatistics() {
|
|
227
|
+
return Array.from(this.tokens.values()).map(entry => ({
|
|
228
|
+
id: entry.id,
|
|
229
|
+
username: entry.username,
|
|
230
|
+
accountType: entry.accountType,
|
|
231
|
+
isActive: entry.isActive,
|
|
232
|
+
hasValidCopilotToken: !!entry.copilotToken,
|
|
233
|
+
copilotTokenExpiresAt: entry.copilotTokenExpiresAt,
|
|
234
|
+
requestCount: entry.requestCount,
|
|
235
|
+
errorCount: entry.errorCount,
|
|
236
|
+
lastUsed: entry.lastUsed,
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// Singleton instance
|
|
241
|
+
export const tokenManager = new TokenManager();
|
package/dist/main.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import "dotenv/config";
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
4
|
+
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
5
|
+
import { cors } from "hono/cors";
|
|
6
|
+
import { logger } from "hono/logger";
|
|
7
|
+
import consola from "consola";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
import { dirname } from "path";
|
|
11
|
+
import { initializeDatabase } from "./lib/database.js";
|
|
12
|
+
import { tokenManager } from "./lib/token-manager.js";
|
|
13
|
+
import { registerOpenAIRoutes } from "./routes/openai/routes.js";
|
|
14
|
+
import { registerAnthropicRoutes } from "./routes/anthropic/routes.js";
|
|
15
|
+
import { registerGeminiRoutes } from "./routes/gemini/routes.js";
|
|
16
|
+
import { registerUtilityRoutes } from "./routes/utility/routes.js";
|
|
17
|
+
import { registerAuthRoutes } from "./routes/auth/routes.js";
|
|
18
|
+
const PORT = parseInt(process.env.PORT || "4242", 10);
|
|
19
|
+
const TOKEN_REFRESH_INTERVAL = 25 * 60 * 1000; // 25 minutes
|
|
20
|
+
// Get the package root directory (for static files)
|
|
21
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
22
|
+
const __dirname = dirname(__filename);
|
|
23
|
+
// When running via npx, COPILOT_ROUTER_ROOT is set by CLI; otherwise use relative path
|
|
24
|
+
const PACKAGE_ROOT = process.env.COPILOT_ROUTER_ROOT || join(__dirname, "..");
|
|
25
|
+
const PUBLIC_DIR = join(PACKAGE_ROOT, "public");
|
|
26
|
+
async function main() {
|
|
27
|
+
consola.info("Starting Copilot Router...");
|
|
28
|
+
// Initialize database
|
|
29
|
+
consola.info("Connecting to SQL Server...");
|
|
30
|
+
await initializeDatabase();
|
|
31
|
+
// Load tokens from database
|
|
32
|
+
consola.info("Loading tokens from database...");
|
|
33
|
+
await tokenManager.loadFromDatabase();
|
|
34
|
+
// Refresh all Copilot tokens
|
|
35
|
+
const tokenCount = tokenManager.getTokenCount();
|
|
36
|
+
if (tokenCount.total > 0) {
|
|
37
|
+
consola.info(`Found ${tokenCount.total} tokens, refreshing Copilot tokens...`);
|
|
38
|
+
const updatedCount = tokenManager.getTokenCount();
|
|
39
|
+
consola.success(`${updatedCount.active} tokens active`);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
consola.warn("No tokens found. Use /auth/login to add tokens or run 'gh auth login'.");
|
|
43
|
+
}
|
|
44
|
+
// Create OpenAPI Hono app
|
|
45
|
+
const app = new OpenAPIHono();
|
|
46
|
+
// Middleware
|
|
47
|
+
app.use(logger());
|
|
48
|
+
app.use(cors());
|
|
49
|
+
// Register utility routes at root
|
|
50
|
+
registerUtilityRoutes(app);
|
|
51
|
+
// Register auth routes
|
|
52
|
+
registerAuthRoutes(app);
|
|
53
|
+
// OpenAI-compatible routes
|
|
54
|
+
const openaiRouter = new OpenAPIHono();
|
|
55
|
+
registerOpenAIRoutes(openaiRouter);
|
|
56
|
+
app.route("/", openaiRouter); // /chat/completions, /models, /embeddings
|
|
57
|
+
app.route("/v1", openaiRouter); // /v1/chat/completions, /v1/models, /v1/embeddings
|
|
58
|
+
// Anthropic-compatible routes
|
|
59
|
+
const anthropicRouter = new OpenAPIHono();
|
|
60
|
+
registerAnthropicRoutes(anthropicRouter);
|
|
61
|
+
app.route("/v1", anthropicRouter); // /v1/messages, /v1/messages/count_tokens
|
|
62
|
+
// Gemini-compatible routes
|
|
63
|
+
const geminiRouter = new OpenAPIHono();
|
|
64
|
+
registerGeminiRoutes(geminiRouter);
|
|
65
|
+
app.route("/v1beta", geminiRouter); // /v1beta/models/:model:generateContent
|
|
66
|
+
// Serve static files (login page)
|
|
67
|
+
app.use("/static/*", serveStatic({ root: PUBLIC_DIR, rewriteRequestPath: (path) => path.replace("/static", "") }));
|
|
68
|
+
app.get("/login", serveStatic({ path: join(PUBLIC_DIR, "index.html") }));
|
|
69
|
+
// OpenAPI documentation
|
|
70
|
+
app.doc("/openapi.json", {
|
|
71
|
+
openapi: "3.0.0",
|
|
72
|
+
info: {
|
|
73
|
+
title: "Copilot Router API",
|
|
74
|
+
version: "1.0.0",
|
|
75
|
+
description: "GitHub Copilot API with OpenAI, Anthropic, and Gemini compatibility",
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
// Start server
|
|
79
|
+
consola.info(`Starting server on port ${PORT}...`);
|
|
80
|
+
serve({
|
|
81
|
+
fetch: app.fetch,
|
|
82
|
+
port: PORT,
|
|
83
|
+
});
|
|
84
|
+
consola.success(`Copilot Router running at http://localhost:${PORT}`);
|
|
85
|
+
consola.info("Available endpoints:");
|
|
86
|
+
consola.info(" Web UI: GET /login");
|
|
87
|
+
consola.info(" Auth: POST /auth/login, POST /auth/complete, GET /auth/tokens");
|
|
88
|
+
consola.info(" OpenAI: POST /v1/chat/completions, GET /v1/models, POST /v1/embeddings");
|
|
89
|
+
consola.info(" Anthropic: POST /v1/messages, POST /v1/messages/count_tokens");
|
|
90
|
+
consola.info(" Gemini: POST /v1beta/models/:model:generateContent");
|
|
91
|
+
consola.info(" Utility: GET /, GET /token, GET /usage, GET /quota");
|
|
92
|
+
consola.info(" Docs: GET /openapi.json");
|
|
93
|
+
}
|
|
94
|
+
main().catch((error) => {
|
|
95
|
+
consola.error("Failed to start server:", error);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createRoute, z } from "@hono/zod-openapi";
|
|
2
|
+
import { streamSSE } from "hono/streaming";
|
|
3
|
+
import consola from "consola";
|
|
4
|
+
import { forwardError } from "../../lib/error.js";
|
|
5
|
+
import { createChatCompletions, } from "../../services/copilot/create-chat-completions.js";
|
|
6
|
+
import { translateToAnthropic, translateToOpenAI } from "./translation.js";
|
|
7
|
+
import { translateChunkToAnthropicEvents } from "./stream-translation.js";
|
|
8
|
+
const AnthropicErrorResponseSchema = z.object({
|
|
9
|
+
type: z.literal("error"),
|
|
10
|
+
error: z.object({
|
|
11
|
+
type: z.string(),
|
|
12
|
+
message: z.string(),
|
|
13
|
+
}),
|
|
14
|
+
});
|
|
15
|
+
// Messages route
|
|
16
|
+
const messagesRoute = createRoute({
|
|
17
|
+
method: "post",
|
|
18
|
+
path: "/messages",
|
|
19
|
+
tags: ["Anthropic API"],
|
|
20
|
+
summary: "Create a message with Anthropic-compatible API",
|
|
21
|
+
description: "Create a message using the Anthropic-compatible API interface, powered by GitHub Copilot.",
|
|
22
|
+
request: {
|
|
23
|
+
body: {
|
|
24
|
+
content: {
|
|
25
|
+
"application/json": {
|
|
26
|
+
schema: z.object({}).passthrough(),
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
responses: {
|
|
32
|
+
200: {
|
|
33
|
+
content: {
|
|
34
|
+
"application/json": {
|
|
35
|
+
schema: z.object({}).passthrough(),
|
|
36
|
+
},
|
|
37
|
+
"text/event-stream": {
|
|
38
|
+
schema: z.string(),
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
description: "Successfully created message",
|
|
42
|
+
},
|
|
43
|
+
400: {
|
|
44
|
+
content: { "application/json": { schema: AnthropicErrorResponseSchema } },
|
|
45
|
+
description: "Bad request",
|
|
46
|
+
},
|
|
47
|
+
500: {
|
|
48
|
+
content: { "application/json": { schema: AnthropicErrorResponseSchema } },
|
|
49
|
+
description: "Internal server error",
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
// Count tokens route
|
|
54
|
+
const countTokensRoute = createRoute({
|
|
55
|
+
method: "post",
|
|
56
|
+
path: "/messages/count_tokens",
|
|
57
|
+
tags: ["Anthropic API"],
|
|
58
|
+
summary: "Count input tokens for Anthropic-compatible messages",
|
|
59
|
+
description: "Count the input tokens for messages using the Anthropic-compatible API interface.",
|
|
60
|
+
request: {
|
|
61
|
+
body: {
|
|
62
|
+
content: {
|
|
63
|
+
"application/json": {
|
|
64
|
+
schema: z.object({}).passthrough(),
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
responses: {
|
|
70
|
+
200: {
|
|
71
|
+
content: {
|
|
72
|
+
"application/json": {
|
|
73
|
+
schema: z.object({
|
|
74
|
+
input_tokens: z.number(),
|
|
75
|
+
}),
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
description: "Successfully counted input tokens",
|
|
79
|
+
},
|
|
80
|
+
500: {
|
|
81
|
+
content: { "application/json": { schema: AnthropicErrorResponseSchema } },
|
|
82
|
+
description: "Internal server error",
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
const isNonStreaming = (response) => Object.hasOwn(response, "choices");
|
|
87
|
+
export function registerAnthropicRoutes(app) {
|
|
88
|
+
// POST /messages
|
|
89
|
+
app.openapi(messagesRoute, async (c) => {
|
|
90
|
+
try {
|
|
91
|
+
const anthropicPayload = await c.req.json();
|
|
92
|
+
consola.debug("Anthropic request payload:", JSON.stringify(anthropicPayload));
|
|
93
|
+
const openAIPayload = translateToOpenAI(anthropicPayload);
|
|
94
|
+
consola.debug("Translated OpenAI request payload:", JSON.stringify(openAIPayload));
|
|
95
|
+
const response = await createChatCompletions(openAIPayload);
|
|
96
|
+
if (isNonStreaming(response)) {
|
|
97
|
+
consola.debug("Non-streaming response from Copilot");
|
|
98
|
+
const anthropicResponse = translateToAnthropic(response);
|
|
99
|
+
return c.json(anthropicResponse);
|
|
100
|
+
}
|
|
101
|
+
consola.debug("Streaming response from Copilot");
|
|
102
|
+
return streamSSE(c, async (stream) => {
|
|
103
|
+
const streamState = {
|
|
104
|
+
messageStartSent: false,
|
|
105
|
+
contentBlockIndex: 0,
|
|
106
|
+
contentBlockOpen: false,
|
|
107
|
+
toolCalls: {},
|
|
108
|
+
};
|
|
109
|
+
for await (const rawEvent of response) {
|
|
110
|
+
if (rawEvent.data === "[DONE]")
|
|
111
|
+
break;
|
|
112
|
+
if (!rawEvent.data)
|
|
113
|
+
continue;
|
|
114
|
+
const chunk = JSON.parse(rawEvent.data);
|
|
115
|
+
const events = translateChunkToAnthropicEvents(chunk, streamState);
|
|
116
|
+
for (const event of events) {
|
|
117
|
+
await stream.writeSSE({
|
|
118
|
+
event: event.type,
|
|
119
|
+
data: JSON.stringify(event),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
return await forwardError(c, error);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
// POST /messages/count_tokens
|
|
130
|
+
app.openapi(countTokensRoute, async (c) => {
|
|
131
|
+
try {
|
|
132
|
+
const anthropicPayload = await c.req.json();
|
|
133
|
+
const openAIPayload = translateToOpenAI(anthropicPayload);
|
|
134
|
+
// Simple estimation: count characters and divide by 4
|
|
135
|
+
let totalChars = 0;
|
|
136
|
+
for (const msg of openAIPayload.messages) {
|
|
137
|
+
if (typeof msg.content === "string") {
|
|
138
|
+
totalChars += msg.content.length;
|
|
139
|
+
}
|
|
140
|
+
else if (Array.isArray(msg.content)) {
|
|
141
|
+
for (const part of msg.content) {
|
|
142
|
+
if (part.type === "text") {
|
|
143
|
+
totalChars += part.text.length;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const estimatedTokens = Math.ceil(totalChars / 4);
|
|
149
|
+
return c.json({ input_tokens: estimatedTokens });
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
return await forwardError(c, error);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type ChatCompletionChunk } from "../../services/copilot/create-chat-completions.js";
|
|
2
|
+
import { type AnthropicStreamEventData, type AnthropicStreamState } from "./types.js";
|
|
3
|
+
export declare function translateChunkToAnthropicEvents(chunk: ChatCompletionChunk, state: AnthropicStreamState): Array<AnthropicStreamEventData>;
|