sub-bridge 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/.cursor/commands/mcp-only.md +1 -0
- package/.github/workflows/npm-publish.yml +33 -0
- package/.github/workflows/pages.yml +40 -0
- package/.github/workflows/release-please.yml +21 -0
- package/.release-please-manifest.json +3 -0
- package/CHANGELOG.md +8 -0
- package/DEVELOPMENT.md +31 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/api/index.ts +12 -0
- package/bun.lock +102 -0
- package/dist/auth/oauth-flow.d.ts +24 -0
- package/dist/auth/oauth-flow.d.ts.map +1 -0
- package/dist/auth/oauth-flow.js +184 -0
- package/dist/auth/oauth-flow.js.map +1 -0
- package/dist/auth/oauth-manager.d.ts +13 -0
- package/dist/auth/oauth-manager.d.ts.map +1 -0
- package/dist/auth/oauth-manager.js +25 -0
- package/dist/auth/oauth-manager.js.map +1 -0
- package/dist/auth/provider.d.ts +42 -0
- package/dist/auth/provider.d.ts.map +1 -0
- package/dist/auth/provider.js +270 -0
- package/dist/auth/provider.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +91 -0
- package/dist/cli.js.map +1 -0
- package/dist/mcp/proxy.d.ts +16 -0
- package/dist/mcp/proxy.d.ts.map +1 -0
- package/dist/mcp/proxy.js +85 -0
- package/dist/mcp/proxy.js.map +1 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +50 -0
- package/dist/mcp.js.map +1 -0
- package/dist/routes/auth.d.ts +6 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +149 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/chat.d.ts +6 -0
- package/dist/routes/chat.d.ts.map +1 -0
- package/dist/routes/chat.js +808 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/tunnels.d.ts +7 -0
- package/dist/routes/tunnels.d.ts.map +1 -0
- package/dist/routes/tunnels.js +44 -0
- package/dist/routes/tunnels.js.map +1 -0
- package/dist/server.d.ts +25 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +157 -0
- package/dist/server.js.map +1 -0
- package/dist/tunnel/providers/cloudflare.d.ts +9 -0
- package/dist/tunnel/providers/cloudflare.d.ts.map +1 -0
- package/dist/tunnel/providers/cloudflare.js +47 -0
- package/dist/tunnel/providers/cloudflare.js.map +1 -0
- package/dist/tunnel/providers/index.d.ts +4 -0
- package/dist/tunnel/providers/index.d.ts.map +1 -0
- package/dist/tunnel/providers/index.js +13 -0
- package/dist/tunnel/providers/index.js.map +1 -0
- package/dist/tunnel/providers/ngrok.d.ts +10 -0
- package/dist/tunnel/providers/ngrok.d.ts.map +1 -0
- package/dist/tunnel/providers/ngrok.js +52 -0
- package/dist/tunnel/providers/ngrok.js.map +1 -0
- package/dist/tunnel/providers/tailscale.d.ts +10 -0
- package/dist/tunnel/providers/tailscale.d.ts.map +1 -0
- package/dist/tunnel/providers/tailscale.js +48 -0
- package/dist/tunnel/providers/tailscale.js.map +1 -0
- package/dist/tunnel/registry.d.ts +14 -0
- package/dist/tunnel/registry.d.ts.map +1 -0
- package/dist/tunnel/registry.js +86 -0
- package/dist/tunnel/registry.js.map +1 -0
- package/dist/tunnel/types.d.ts +26 -0
- package/dist/tunnel/types.d.ts.map +1 -0
- package/dist/tunnel/types.js +6 -0
- package/dist/tunnel/types.js.map +1 -0
- package/dist/tunnel/utils.d.ts +18 -0
- package/dist/tunnel/utils.d.ts.map +1 -0
- package/dist/tunnel/utils.js +57 -0
- package/dist/tunnel/utils.js.map +1 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/anthropic-to-openai-converter.d.ts +103 -0
- package/dist/utils/anthropic-to-openai-converter.d.ts.map +1 -0
- package/dist/utils/anthropic-to-openai-converter.js +376 -0
- package/dist/utils/anthropic-to-openai-converter.js.map +1 -0
- package/dist/utils/chat-to-responses.d.ts +59 -0
- package/dist/utils/chat-to-responses.d.ts.map +1 -0
- package/dist/utils/chat-to-responses.js +395 -0
- package/dist/utils/chat-to-responses.js.map +1 -0
- package/dist/utils/chatgpt-instructions.d.ts +3 -0
- package/dist/utils/chatgpt-instructions.d.ts.map +1 -0
- package/dist/utils/chatgpt-instructions.js +12 -0
- package/dist/utils/chatgpt-instructions.js.map +1 -0
- package/dist/utils/cli-args.d.ts +3 -0
- package/dist/utils/cli-args.d.ts.map +1 -0
- package/dist/utils/cli-args.js +10 -0
- package/dist/utils/cli-args.js.map +1 -0
- package/dist/utils/cors-bypass.d.ts +4 -0
- package/dist/utils/cors-bypass.d.ts.map +1 -0
- package/dist/utils/cors-bypass.js +30 -0
- package/dist/utils/cors-bypass.js.map +1 -0
- package/dist/utils/cursor-byok-bypass.d.ts +37 -0
- package/dist/utils/cursor-byok-bypass.d.ts.map +1 -0
- package/dist/utils/cursor-byok-bypass.js +53 -0
- package/dist/utils/cursor-byok-bypass.js.map +1 -0
- package/dist/utils/logger.d.ts +19 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +192 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/port.d.ts +27 -0
- package/dist/utils/port.d.ts.map +1 -0
- package/dist/utils/port.js +78 -0
- package/dist/utils/port.js.map +1 -0
- package/dist/utils/setup-instructions.d.ts +10 -0
- package/dist/utils/setup-instructions.d.ts.map +1 -0
- package/dist/utils/setup-instructions.js +49 -0
- package/dist/utils/setup-instructions.js.map +1 -0
- package/env.example +25 -0
- package/index.html +992 -0
- package/package.json +57 -0
- package/public/.nojekyll +0 -0
- package/public/assets/chat.png +0 -0
- package/public/assets/demo.gif +0 -0
- package/public/assets/demo.mp4 +0 -0
- package/public/assets/setup.png +0 -0
- package/public/assets/ui.png +0 -0
- package/public/index.html +292 -0
- package/release-please-config.json +10 -0
- package/src/auth/provider.ts +412 -0
- package/src/cli.ts +97 -0
- package/src/mcp/proxy.ts +64 -0
- package/src/mcp.ts +56 -0
- package/src/oauth/authorize.ts +270 -0
- package/src/oauth/crypto.ts +198 -0
- package/src/oauth/dcr.ts +129 -0
- package/src/oauth/metadata.ts +40 -0
- package/src/oauth/token.ts +173 -0
- package/src/routes/auth.ts +149 -0
- package/src/routes/chat.ts +983 -0
- package/src/routes/oauth.ts +220 -0
- package/src/routes/tunnels.ts +45 -0
- package/src/server.ts +204 -0
- package/src/tunnel/providers/cloudflare.ts +50 -0
- package/src/tunnel/providers/index.ts +7 -0
- package/src/tunnel/providers/ngrok.ts +56 -0
- package/src/tunnel/providers/tailscale.ts +50 -0
- package/src/tunnel/registry.ts +96 -0
- package/src/tunnel/types.ts +32 -0
- package/src/tunnel/utils.ts +59 -0
- package/src/types.ts +55 -0
- package/src/utils/anthropic-to-openai-converter.ts +578 -0
- package/src/utils/chat-to-responses.ts +512 -0
- package/src/utils/chatgpt-instructions.ts +7 -0
- package/src/utils/cli-args.ts +8 -0
- package/src/utils/cors-bypass.ts +39 -0
- package/src/utils/cursor-byok-bypass.ts +56 -0
- package/src/utils/logger.ts +174 -0
- package/src/utils/port.ts +99 -0
- package/src/utils/setup-instructions.ts +59 -0
- package/tsconfig.json +22 -0
- package/vercel.json +20 -0
|
@@ -0,0 +1,983 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat completion routes - handles OpenAI and Claude API proxying
|
|
3
|
+
*/
|
|
4
|
+
import { Hono, Context } from 'hono'
|
|
5
|
+
import { stream } from 'hono/streaming'
|
|
6
|
+
import {
|
|
7
|
+
createConverterState,
|
|
8
|
+
processChunk,
|
|
9
|
+
convertNonStreamingResponse,
|
|
10
|
+
} from '../utils/anthropic-to-openai-converter'
|
|
11
|
+
import {
|
|
12
|
+
isCursorKeyCheck,
|
|
13
|
+
createCursorBypassResponse,
|
|
14
|
+
} from '../utils/cursor-byok-bypass'
|
|
15
|
+
import { logRequest, logResponse, logError, isVerbose, logHeaders, logStreamChunk } from '../utils/logger'
|
|
16
|
+
import { getChatGptInstructions } from '../utils/chatgpt-instructions'
|
|
17
|
+
import { convertToResponsesFormat } from '../utils/chat-to-responses'
|
|
18
|
+
import { decodeAccessToken } from '../oauth/crypto'
|
|
19
|
+
import { refreshClaudeToken } from '../auth/provider'
|
|
20
|
+
|
|
21
|
+
// Model alias mapping (short names → full Claude model IDs)
|
|
22
|
+
const MODEL_ALIASES: Record<string, string> = {
|
|
23
|
+
'opus-4.5': 'claude-opus-4-5-20251101',
|
|
24
|
+
'sonnet-4.5': 'claude-sonnet-4-5-20250514',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ModelMapping {
|
|
28
|
+
from: string // e.g., 'o3'
|
|
29
|
+
to: string // e.g., 'claude-opus-4-5-20251101'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface KeyConfig {
|
|
33
|
+
mappings: ModelMapping[]
|
|
34
|
+
apiKey: string
|
|
35
|
+
accountId?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ParsedKeys {
|
|
39
|
+
configs: KeyConfig[]
|
|
40
|
+
defaultKey?: string // fallback for ultrathink/unmatched
|
|
41
|
+
defaultAccountId?: string
|
|
42
|
+
oauth?: OAuthTokens
|
|
43
|
+
oauthError?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface TokenInfo {
|
|
47
|
+
token: string
|
|
48
|
+
accountId?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface OAuthTokens {
|
|
52
|
+
claudeToken?: string
|
|
53
|
+
claudeRefreshToken?: string
|
|
54
|
+
chatgptToken?: string
|
|
55
|
+
chatgptAccountId?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const CHATGPT_BASE_URL = process.env.CHATGPT_BASE_URL || 'https://chatgpt.com/backend-api/codex'
|
|
59
|
+
const CHATGPT_DEFAULT_MODEL = process.env.CHATGPT_DEFAULT_MODEL || 'gpt-5.2-codex'
|
|
60
|
+
|
|
61
|
+
function splitProviderTokens(fullToken: string): string[] {
|
|
62
|
+
if (!fullToken) return []
|
|
63
|
+
const bySpace = fullToken.split(/\s+/).filter(Boolean)
|
|
64
|
+
if (bySpace.length > 1) return bySpace
|
|
65
|
+
|
|
66
|
+
const single = bySpace[0] || ''
|
|
67
|
+
if (!single.includes(',')) return single ? [single] : []
|
|
68
|
+
|
|
69
|
+
const lastColon = single.lastIndexOf(':')
|
|
70
|
+
if (lastColon !== -1) {
|
|
71
|
+
const mappingPart = single.slice(0, lastColon)
|
|
72
|
+
const tokenPart = single.slice(lastColon + 1)
|
|
73
|
+
if (tokenPart.includes(',')) {
|
|
74
|
+
const splitTokens = tokenPart.split(',').map((t) => t.trim()).filter(Boolean)
|
|
75
|
+
if (splitTokens.length > 0) {
|
|
76
|
+
return [`${mappingPart}:${splitTokens[0]}`, ...splitTokens.slice(1)]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return single.split(',').map((t) => t.trim()).filter(Boolean)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parseTokenWithAccount(token: string): TokenInfo {
|
|
85
|
+
const hashIndex = token.indexOf('#')
|
|
86
|
+
if (hashIndex > 0) {
|
|
87
|
+
return {
|
|
88
|
+
token: token.slice(0, hashIndex),
|
|
89
|
+
accountId: token.slice(hashIndex + 1),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return { token }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isJwtToken(token: string): boolean {
|
|
96
|
+
const parts = token.split('.')
|
|
97
|
+
return parts.length === 3 && parts.every((p) => p.length > 0)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function mergeOAuthTokens(target: OAuthTokens, incoming: OAuthTokens | null) {
|
|
101
|
+
if (!incoming) return
|
|
102
|
+
if (incoming.claudeToken && !target.claudeToken) target.claudeToken = incoming.claudeToken
|
|
103
|
+
if (incoming.claudeRefreshToken && !target.claudeRefreshToken) target.claudeRefreshToken = incoming.claudeRefreshToken
|
|
104
|
+
if (incoming.chatgptToken && !target.chatgptToken) target.chatgptToken = incoming.chatgptToken
|
|
105
|
+
if (incoming.chatgptAccountId && !target.chatgptAccountId) target.chatgptAccountId = incoming.chatgptAccountId
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseOAuthToken(token: string): { tokens?: OAuthTokens; error?: string } | null {
|
|
109
|
+
if (!token.startsWith('sb1.')) return null
|
|
110
|
+
const payload = decodeAccessToken(token)
|
|
111
|
+
if (!payload) {
|
|
112
|
+
return { error: 'OAuth token invalid or expired. Please re-authenticate.' }
|
|
113
|
+
}
|
|
114
|
+
const result: OAuthTokens = {}
|
|
115
|
+
if (payload.providers.claude?.access_token) result.claudeToken = payload.providers.claude.access_token
|
|
116
|
+
if (payload.providers.claude?.refresh_token) result.claudeRefreshToken = payload.providers.claude.refresh_token
|
|
117
|
+
if (payload.providers.chatgpt?.access_token) result.chatgptToken = payload.providers.chatgpt.access_token
|
|
118
|
+
if (payload.providers.chatgpt?.account_id) result.chatgptAccountId = payload.providers.chatgpt.account_id
|
|
119
|
+
return { tokens: result }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isSubBridgeToken(token: string): boolean {
|
|
123
|
+
return token.startsWith('sb1.')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeChatGptModel(requestedModel: string): string {
|
|
127
|
+
if (!requestedModel) return CHATGPT_DEFAULT_MODEL
|
|
128
|
+
if (requestedModel.includes('codex')) return requestedModel
|
|
129
|
+
if (requestedModel.startsWith('gpt-5.2')) return CHATGPT_DEFAULT_MODEL
|
|
130
|
+
if (requestedModel.startsWith('gpt-5')) return CHATGPT_DEFAULT_MODEL
|
|
131
|
+
return CHATGPT_DEFAULT_MODEL
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Parse routed API keys from Authorization header
|
|
136
|
+
* Format: "o3=opus-4.5,o3-mini=sonnet-4.5:sk-ant-xxx sk-xxx" (space-separated tokens; comma fallback supported) or just "sk-ant-xxx" for default
|
|
137
|
+
*/
|
|
138
|
+
function parseRoutedKeys(authHeader: string | undefined): ParsedKeys {
|
|
139
|
+
if (!authHeader) return { configs: [] }
|
|
140
|
+
const fullToken = authHeader.replace(/^Bearer\s+/i, '').trim()
|
|
141
|
+
|
|
142
|
+
// Split by space (or comma fallback) to handle multiple provider tokens
|
|
143
|
+
const tokens = splitProviderTokens(fullToken)
|
|
144
|
+
const configs: KeyConfig[] = []
|
|
145
|
+
let defaultKey: string | undefined
|
|
146
|
+
let defaultAccountId: string | undefined
|
|
147
|
+
const oauthTokens: OAuthTokens = {}
|
|
148
|
+
let oauthError: string | undefined
|
|
149
|
+
|
|
150
|
+
for (const token of tokens) {
|
|
151
|
+
if (!token) continue
|
|
152
|
+
|
|
153
|
+
// Check if it's a routed key (contains '=')
|
|
154
|
+
if (!token.includes('=')) {
|
|
155
|
+
const parsed = parseTokenWithAccount(token)
|
|
156
|
+
if (isSubBridgeToken(parsed.token)) {
|
|
157
|
+
const oauthParsed = parseOAuthToken(parsed.token)
|
|
158
|
+
if (oauthParsed?.error) oauthError = oauthParsed.error
|
|
159
|
+
if (oauthParsed?.tokens) mergeOAuthTokens(oauthTokens, oauthParsed.tokens)
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
// Plain key without routing - use as default
|
|
163
|
+
if (!defaultKey) {
|
|
164
|
+
defaultKey = parsed.token
|
|
165
|
+
defaultAccountId = parsed.accountId
|
|
166
|
+
}
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Split by last colon to separate mappings from key
|
|
171
|
+
const lastColon = token.lastIndexOf(':')
|
|
172
|
+
if (lastColon === -1) {
|
|
173
|
+
// No colon found in routed key, skip it
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const mappingsPart = token.slice(0, lastColon)
|
|
178
|
+
const parsedToken = parseTokenWithAccount(token.slice(lastColon + 1))
|
|
179
|
+
let apiKey = parsedToken.token
|
|
180
|
+
if (isSubBridgeToken(apiKey)) {
|
|
181
|
+
const oauthParsed = parseOAuthToken(apiKey)
|
|
182
|
+
if (oauthParsed?.error) {
|
|
183
|
+
oauthError = oauthParsed.error
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
if (oauthParsed?.tokens) {
|
|
187
|
+
mergeOAuthTokens(oauthTokens, oauthParsed.tokens)
|
|
188
|
+
if (oauthParsed.tokens.claudeToken) {
|
|
189
|
+
apiKey = oauthParsed.tokens.claudeToken
|
|
190
|
+
} else {
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const mappings = mappingsPart.split(',').map(m => {
|
|
197
|
+
const [from, to] = m.split('=')
|
|
198
|
+
const resolvedTo = MODEL_ALIASES[to] || to
|
|
199
|
+
return { from: from.trim(), to: resolvedTo }
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
configs.push({ mappings, apiKey, accountId: parsedToken.accountId })
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const hasOAuth = Boolean(oauthTokens.claudeToken || oauthTokens.chatgptToken || oauthTokens.chatgptAccountId)
|
|
206
|
+
if (hasOAuth || oauthError) {
|
|
207
|
+
return { configs, defaultKey, defaultAccountId, oauth: hasOAuth ? oauthTokens : undefined, oauthError }
|
|
208
|
+
}
|
|
209
|
+
return { configs, defaultKey, defaultAccountId }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Find the Claude model and API key for a given requested model
|
|
214
|
+
*/
|
|
215
|
+
function resolveModelRouting(requestedModel: string, parsedKeys: ParsedKeys): { claudeModel: string; apiKey: string } | null {
|
|
216
|
+
// Check all configs for a matching route
|
|
217
|
+
for (const config of parsedKeys.configs) {
|
|
218
|
+
for (const mapping of config.mappings) {
|
|
219
|
+
if (mapping.from === requestedModel) {
|
|
220
|
+
return { claudeModel: mapping.to, apiKey: config.apiKey }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// If model starts with 'claude-', use default key
|
|
226
|
+
if (requestedModel.startsWith('claude-') && parsedKeys.defaultKey) {
|
|
227
|
+
return { claudeModel: requestedModel, apiKey: parsedKeys.defaultKey }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Fallback to default key with the model as-is (for ultrathink)
|
|
231
|
+
if (parsedKeys.defaultKey) {
|
|
232
|
+
return { claudeModel: requestedModel, apiKey: parsedKeys.defaultKey }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function convertMessages(messages: any[]): any[] {
|
|
239
|
+
const converted: any[] = []
|
|
240
|
+
|
|
241
|
+
for (const msg of messages) {
|
|
242
|
+
if (msg.type === 'custom_tool_call' || msg.type === 'function_call') {
|
|
243
|
+
let toolInput = msg.input || msg.arguments
|
|
244
|
+
if (typeof toolInput === 'string') {
|
|
245
|
+
try { toolInput = JSON.parse(toolInput) } catch { toolInput = { command: toolInput } }
|
|
246
|
+
}
|
|
247
|
+
const toolUse = { type: 'tool_use', id: msg.call_id, name: msg.name, input: toolInput || {} }
|
|
248
|
+
const last = converted[converted.length - 1]
|
|
249
|
+
if (last?.role === 'assistant' && Array.isArray(last.content)) last.content.push(toolUse)
|
|
250
|
+
else converted.push({ role: 'assistant', content: [toolUse] })
|
|
251
|
+
continue
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (msg.type === 'custom_tool_call_output' || msg.type === 'function_call_output') {
|
|
255
|
+
const toolResult = { type: 'tool_result', tool_use_id: msg.call_id, content: msg.output || '' }
|
|
256
|
+
const last = converted[converted.length - 1]
|
|
257
|
+
if (last?.role === 'user' && Array.isArray(last.content) && last.content[0]?.type === 'tool_result') last.content.push(toolResult)
|
|
258
|
+
else converted.push({ role: 'user', content: [toolResult] })
|
|
259
|
+
continue
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!msg.role) continue
|
|
263
|
+
|
|
264
|
+
if (msg.role === 'assistant' && msg.tool_calls?.length) {
|
|
265
|
+
let content: any[] = []
|
|
266
|
+
if (msg.content) {
|
|
267
|
+
if (typeof msg.content === 'string') {
|
|
268
|
+
content = [{ type: 'text', text: msg.content }]
|
|
269
|
+
} else if (Array.isArray(msg.content)) {
|
|
270
|
+
// Preserve existing content blocks, converting to Claude format
|
|
271
|
+
for (const block of msg.content) {
|
|
272
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
273
|
+
content.push({ type: 'text', text: block.text })
|
|
274
|
+
} else if (block.type === 'tool_use') {
|
|
275
|
+
content.push(block) // Already Claude format
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
for (const tc of msg.tool_calls) {
|
|
281
|
+
let input = tc.function?.arguments || tc.arguments || {}
|
|
282
|
+
if (typeof input === 'string') {
|
|
283
|
+
try {
|
|
284
|
+
input = JSON.parse(input || '{}')
|
|
285
|
+
} catch {
|
|
286
|
+
input = { raw: input }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
content.push({
|
|
290
|
+
type: 'tool_use', id: tc.id, name: tc.function?.name || tc.name, input
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
converted.push({ role: 'assistant', content })
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (msg.role === 'tool') {
|
|
298
|
+
const toolResult = { type: 'tool_result', tool_use_id: msg.tool_call_id, content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) }
|
|
299
|
+
const last = converted[converted.length - 1]
|
|
300
|
+
if (last?.role === 'user' && Array.isArray(last.content) && last.content[0]?.type === 'tool_result') last.content.push(toolResult)
|
|
301
|
+
else converted.push({ role: 'user', content: [toolResult] })
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Handle assistant messages with array content (no tool_calls)
|
|
306
|
+
if (msg.role === 'assistant' && Array.isArray(msg.content)) {
|
|
307
|
+
const content: any[] = []
|
|
308
|
+
for (const block of msg.content) {
|
|
309
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
310
|
+
content.push({ type: 'text', text: block.text })
|
|
311
|
+
} else if (block.type === 'tool_use') {
|
|
312
|
+
content.push(block)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
converted.push({ role: 'assistant', content: content.length > 0 ? content : '' })
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
converted.push({ role: msg.role, content: msg.content ?? '' })
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const last = converted[converted.length - 1]
|
|
323
|
+
if (last?.role === 'assistant') {
|
|
324
|
+
if (typeof last.content === 'string') last.content = last.content.trimEnd() || '...'
|
|
325
|
+
else if (Array.isArray(last.content)) {
|
|
326
|
+
for (const block of last.content) {
|
|
327
|
+
if (block.type === 'text') block.text = (block.text?.trimEnd()) || '...'
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return converted
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function buildChatGptResponsesBody(body: any, requestedModel: string, isStreaming: boolean) {
|
|
336
|
+
// ChatGPT backend requires EXACTLY the base codex prompt as instructions
|
|
337
|
+
const instructions = getChatGptInstructions()
|
|
338
|
+
|
|
339
|
+
// Use the robust converter to handle all message formats
|
|
340
|
+
const { input, developerMessages } = convertToResponsesFormat(body)
|
|
341
|
+
|
|
342
|
+
// Prepend developer messages to input
|
|
343
|
+
const fullInput = [...developerMessages, ...input]
|
|
344
|
+
|
|
345
|
+
// Fallback if no input
|
|
346
|
+
if (fullInput.length === 0) {
|
|
347
|
+
fullInput.push({
|
|
348
|
+
type: 'message',
|
|
349
|
+
role: 'user',
|
|
350
|
+
content: [{ type: 'input_text', text: 'Hello.' }],
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const tools = Array.isArray(body.tools) ? body.tools : []
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
model: requestedModel,
|
|
358
|
+
instructions,
|
|
359
|
+
input: fullInput,
|
|
360
|
+
tools,
|
|
361
|
+
tool_choice: 'auto',
|
|
362
|
+
parallel_tool_calls: false,
|
|
363
|
+
stream: true, // Backend REQUIRES stream: true
|
|
364
|
+
store: false,
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function createChatGptStreamState(model: string) {
|
|
369
|
+
const now = Math.floor(Date.now() / 1000)
|
|
370
|
+
return {
|
|
371
|
+
buffer: '',
|
|
372
|
+
id: `chatcmpl-${Date.now().toString(36)}`,
|
|
373
|
+
model,
|
|
374
|
+
created: now,
|
|
375
|
+
roleSent: false,
|
|
376
|
+
sawTextDelta: false,
|
|
377
|
+
toolCallsSeen: false,
|
|
378
|
+
toolCallIndex: 0,
|
|
379
|
+
processedItemIds: new Set<string>(), // Track processed item IDs to prevent duplicates
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function createChatChunk(state: ReturnType<typeof createChatGptStreamState>, delta: any, finishReason: string | null, usage?: any) {
|
|
384
|
+
return {
|
|
385
|
+
id: state.id,
|
|
386
|
+
object: 'chat.completion.chunk',
|
|
387
|
+
created: state.created,
|
|
388
|
+
model: state.model,
|
|
389
|
+
choices: [
|
|
390
|
+
{
|
|
391
|
+
index: 0,
|
|
392
|
+
delta,
|
|
393
|
+
finish_reason: finishReason,
|
|
394
|
+
},
|
|
395
|
+
],
|
|
396
|
+
...(usage ? { usage } : {}),
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function mapUsage(usage: any) {
|
|
401
|
+
if (!usage) return undefined
|
|
402
|
+
const prompt = usage.input_tokens ?? usage.prompt_tokens ?? 0
|
|
403
|
+
const completion = usage.output_tokens ?? usage.completion_tokens ?? 0
|
|
404
|
+
const total = usage.total_tokens ?? (prompt + completion)
|
|
405
|
+
return {
|
|
406
|
+
prompt_tokens: prompt,
|
|
407
|
+
completion_tokens: completion,
|
|
408
|
+
total_tokens: total,
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function processChatGptChunk(state: ReturnType<typeof createChatGptStreamState>, chunk: string) {
|
|
413
|
+
state.buffer += chunk
|
|
414
|
+
const results: Array<{ type: 'chunk' | 'done'; data?: any }> = []
|
|
415
|
+
const parts = state.buffer.split('\n\n')
|
|
416
|
+
state.buffer = parts.pop() || ''
|
|
417
|
+
|
|
418
|
+
for (const part of parts) {
|
|
419
|
+
const lines = part.split('\n')
|
|
420
|
+
for (const line of lines) {
|
|
421
|
+
if (!line.startsWith('data:')) continue
|
|
422
|
+
const data = line.slice(5).trim()
|
|
423
|
+
if (!data || data === '[DONE]') continue
|
|
424
|
+
let payload: any
|
|
425
|
+
try {
|
|
426
|
+
payload = JSON.parse(data)
|
|
427
|
+
} catch {
|
|
428
|
+
continue
|
|
429
|
+
}
|
|
430
|
+
const kind = payload?.type
|
|
431
|
+
if (kind === 'response.created' && payload.response?.id) {
|
|
432
|
+
state.id = `chatcmpl-${String(payload.response.id).replace(/^resp_/, '')}`
|
|
433
|
+
continue
|
|
434
|
+
}
|
|
435
|
+
if (kind === 'response.output_text.delta' && typeof payload.delta === 'string') {
|
|
436
|
+
const delta: any = { content: payload.delta }
|
|
437
|
+
if (!state.roleSent) {
|
|
438
|
+
delta.role = 'assistant'
|
|
439
|
+
state.roleSent = true
|
|
440
|
+
}
|
|
441
|
+
state.sawTextDelta = true
|
|
442
|
+
results.push({ type: 'chunk', data: createChatChunk(state, delta, null) })
|
|
443
|
+
continue
|
|
444
|
+
}
|
|
445
|
+
if (kind === 'response.output_item.done' && payload.item) {
|
|
446
|
+
const item = payload.item
|
|
447
|
+
|
|
448
|
+
// Generate item identifier and skip if already processed
|
|
449
|
+
const itemId = item.id || item.call_id || `${item.type}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
450
|
+
if (state.processedItemIds.has(itemId)) {
|
|
451
|
+
continue // Already processed this item, skip to prevent duplicates
|
|
452
|
+
}
|
|
453
|
+
state.processedItemIds.add(itemId)
|
|
454
|
+
|
|
455
|
+
if (item.type === 'message' && item.role === 'assistant' && !state.sawTextDelta) {
|
|
456
|
+
const blocks = Array.isArray(item.content) ? item.content : []
|
|
457
|
+
const text = blocks
|
|
458
|
+
.filter((b: any) => b?.type === 'output_text' && typeof b.text === 'string')
|
|
459
|
+
.map((b: any) => b.text)
|
|
460
|
+
.join('')
|
|
461
|
+
if (text) {
|
|
462
|
+
const delta: any = { content: text }
|
|
463
|
+
if (!state.roleSent) {
|
|
464
|
+
delta.role = 'assistant'
|
|
465
|
+
state.roleSent = true
|
|
466
|
+
}
|
|
467
|
+
results.push({ type: 'chunk', data: createChatChunk(state, delta, null) })
|
|
468
|
+
}
|
|
469
|
+
} else if (item.type === 'function_call') {
|
|
470
|
+
state.toolCallsSeen = true
|
|
471
|
+
const toolCallId = item.call_id || item.id || `call_${state.toolCallIndex}`
|
|
472
|
+
|
|
473
|
+
const delta: any = {
|
|
474
|
+
tool_calls: [
|
|
475
|
+
{
|
|
476
|
+
index: state.toolCallIndex++,
|
|
477
|
+
id: toolCallId,
|
|
478
|
+
type: 'function',
|
|
479
|
+
function: {
|
|
480
|
+
name: item.name || 'unknown',
|
|
481
|
+
arguments: item.arguments || '',
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
],
|
|
485
|
+
}
|
|
486
|
+
if (!state.roleSent) {
|
|
487
|
+
delta.role = 'assistant'
|
|
488
|
+
state.roleSent = true
|
|
489
|
+
}
|
|
490
|
+
results.push({ type: 'chunk', data: createChatChunk(state, delta, null) })
|
|
491
|
+
}
|
|
492
|
+
continue
|
|
493
|
+
}
|
|
494
|
+
if (kind === 'response.completed') {
|
|
495
|
+
const usage = mapUsage(payload.response?.usage)
|
|
496
|
+
const finish = state.toolCallsSeen ? 'tool_calls' : 'stop'
|
|
497
|
+
results.push({ type: 'chunk', data: createChatChunk(state, {}, finish, usage) })
|
|
498
|
+
results.push({ type: 'done' })
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return results
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function handleOpenAIProxy(c: Context, body: any, requestedModel: string, openaiToken: string, isStreaming: boolean) {
|
|
507
|
+
logRequest('openai', requestedModel, {})
|
|
508
|
+
|
|
509
|
+
// Forward request directly to OpenAI API
|
|
510
|
+
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
511
|
+
method: 'POST',
|
|
512
|
+
headers: {
|
|
513
|
+
'content-type': 'application/json',
|
|
514
|
+
'authorization': `Bearer ${openaiToken}`,
|
|
515
|
+
},
|
|
516
|
+
body: JSON.stringify(body),
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
if (!response.ok) {
|
|
520
|
+
const errorText = await response.text()
|
|
521
|
+
logError(errorText.slice(0, 200))
|
|
522
|
+
|
|
523
|
+
// Try to parse OpenAI error
|
|
524
|
+
try {
|
|
525
|
+
const openAIError = JSON.parse(errorText)
|
|
526
|
+
return c.json(openAIError, response.status as any)
|
|
527
|
+
} catch (parseError) {
|
|
528
|
+
return new Response(errorText, { status: response.status })
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
logResponse(response.status)
|
|
533
|
+
|
|
534
|
+
// For streaming, pass through the stream
|
|
535
|
+
if (isStreaming) {
|
|
536
|
+
return new Response(response.body, {
|
|
537
|
+
status: response.status,
|
|
538
|
+
headers: {
|
|
539
|
+
'content-type': 'text/event-stream',
|
|
540
|
+
'cache-control': 'no-cache',
|
|
541
|
+
'connection': 'keep-alive',
|
|
542
|
+
},
|
|
543
|
+
})
|
|
544
|
+
} else {
|
|
545
|
+
// For non-streaming, pass through the JSON response
|
|
546
|
+
const responseData = await response.json()
|
|
547
|
+
return c.json(responseData)
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function handleChatGptProxy(
|
|
552
|
+
c: Context,
|
|
553
|
+
body: any,
|
|
554
|
+
requestedModel: string,
|
|
555
|
+
tokenInfo: TokenInfo,
|
|
556
|
+
isStreaming: boolean,
|
|
557
|
+
) {
|
|
558
|
+
const chatgptModel = normalizeChatGptModel(requestedModel)
|
|
559
|
+
const responseBody = buildChatGptResponsesBody(body, chatgptModel, isStreaming)
|
|
560
|
+
logRequest('chatgpt', `${requestedModel} → ${chatgptModel}`, {
|
|
561
|
+
system: responseBody.instructions,
|
|
562
|
+
messages: responseBody.input,
|
|
563
|
+
tools: responseBody.tools,
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
if (!tokenInfo.accountId) {
|
|
567
|
+
return c.json({
|
|
568
|
+
error: {
|
|
569
|
+
message: 'ChatGPT account id missing. Re-login to refresh your ChatGPT token.',
|
|
570
|
+
type: 'authentication_error',
|
|
571
|
+
code: 'authentication_error',
|
|
572
|
+
}
|
|
573
|
+
}, 401)
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const baseUrl = CHATGPT_BASE_URL.replace(/\/$/, '')
|
|
577
|
+
|
|
578
|
+
if (isVerbose()) {
|
|
579
|
+
logHeaders('Request Headers', {
|
|
580
|
+
'content-type': 'application/json',
|
|
581
|
+
'authorization': `Bearer ${tokenInfo.token}`,
|
|
582
|
+
'chatgpt-account-id': tokenInfo.accountId || '',
|
|
583
|
+
'originator': 'codex_cli_rs',
|
|
584
|
+
'accept': 'text/event-stream',
|
|
585
|
+
})
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const response = await fetch(`${baseUrl}/responses`, {
|
|
589
|
+
method: 'POST',
|
|
590
|
+
headers: {
|
|
591
|
+
'content-type': 'application/json',
|
|
592
|
+
'authorization': `Bearer ${tokenInfo.token}`,
|
|
593
|
+
'chatgpt-account-id': tokenInfo.accountId,
|
|
594
|
+
'originator': 'codex_cli_rs',
|
|
595
|
+
'accept': 'text/event-stream', // Backend always requires streaming
|
|
596
|
+
},
|
|
597
|
+
body: JSON.stringify(responseBody),
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
if (!response.ok) {
|
|
601
|
+
const errorText = await response.text()
|
|
602
|
+
logError(errorText.slice(0, 200))
|
|
603
|
+
try {
|
|
604
|
+
const parsed = JSON.parse(errorText)
|
|
605
|
+
const errorMessage = parsed.error?.message || parsed.message || 'Unknown error'
|
|
606
|
+
const errorType = parsed.error?.type || parsed.type || 'api_error'
|
|
607
|
+
|
|
608
|
+
// Map error types to user-friendly messages
|
|
609
|
+
let userMessage = errorMessage
|
|
610
|
+
if (response.status === 401 || errorType === 'authentication_error') {
|
|
611
|
+
userMessage = `ChatGPT authentication failed: ${errorMessage}. Try re-logging in.`
|
|
612
|
+
} else if (response.status === 429 || errorType === 'rate_limit_error') {
|
|
613
|
+
userMessage = `ChatGPT rate limit exceeded: ${errorMessage}`
|
|
614
|
+
} else if (response.status === 400 || errorType === 'invalid_request_error') {
|
|
615
|
+
userMessage = `Invalid request to ChatGPT: ${errorMessage}`
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return c.json({
|
|
619
|
+
error: {
|
|
620
|
+
message: userMessage,
|
|
621
|
+
type: errorType,
|
|
622
|
+
code: errorType,
|
|
623
|
+
}
|
|
624
|
+
}, response.status as any)
|
|
625
|
+
} catch {
|
|
626
|
+
return c.json({
|
|
627
|
+
error: {
|
|
628
|
+
message: `ChatGPT error: ${errorText.slice(0, 200)}`,
|
|
629
|
+
type: 'api_error',
|
|
630
|
+
code: 'api_error',
|
|
631
|
+
}
|
|
632
|
+
}, response.status as any)
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
logResponse(response.status)
|
|
637
|
+
|
|
638
|
+
if (isVerbose()) {
|
|
639
|
+
const respHeaders: Record<string, string> = {}
|
|
640
|
+
response.headers.forEach((value, key) => {
|
|
641
|
+
respHeaders[key] = value
|
|
642
|
+
})
|
|
643
|
+
logHeaders('Response Headers', respHeaders)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const reader = response.body!.getReader()
|
|
647
|
+
const decoder = new TextDecoder()
|
|
648
|
+
const state = createChatGptStreamState(chatgptModel)
|
|
649
|
+
|
|
650
|
+
if (isStreaming) {
|
|
651
|
+
return stream(c, async (s) => {
|
|
652
|
+
while (true) {
|
|
653
|
+
const { done, value } = await reader.read()
|
|
654
|
+
if (done) break
|
|
655
|
+
const chunk = decoder.decode(value, { stream: true })
|
|
656
|
+
if (isVerbose()) {
|
|
657
|
+
logStreamChunk(chunk)
|
|
658
|
+
}
|
|
659
|
+
const results = processChatGptChunk(state, chunk)
|
|
660
|
+
for (const result of results) {
|
|
661
|
+
if (result.type === 'chunk') {
|
|
662
|
+
await s.write(`data: ${JSON.stringify(result.data)}\n\n`)
|
|
663
|
+
} else if (result.type === 'done') {
|
|
664
|
+
await s.write('data: [DONE]\n\n')
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
reader.releaseLock()
|
|
669
|
+
})
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Non-streaming: aggregate the stream into a final response
|
|
673
|
+
let fullContent = ''
|
|
674
|
+
const toolCallsMap = new Map<string, any>() // Use Map for deduplication by ID
|
|
675
|
+
let usage: any = null
|
|
676
|
+
|
|
677
|
+
while (true) {
|
|
678
|
+
const { done, value } = await reader.read()
|
|
679
|
+
if (done) break
|
|
680
|
+
const chunk = decoder.decode(value, { stream: true })
|
|
681
|
+
if (isVerbose()) {
|
|
682
|
+
logStreamChunk(chunk)
|
|
683
|
+
}
|
|
684
|
+
const results = processChatGptChunk(state, chunk)
|
|
685
|
+
for (const result of results) {
|
|
686
|
+
if (result.type === 'chunk' && result.data?.choices?.[0]?.delta) {
|
|
687
|
+
const delta = result.data.choices[0].delta
|
|
688
|
+
if (delta.content) fullContent += delta.content
|
|
689
|
+
|
|
690
|
+
// Aggregate tool calls - merge by index, deduplicate by ID
|
|
691
|
+
if (delta.tool_calls) {
|
|
692
|
+
for (const tc of delta.tool_calls) {
|
|
693
|
+
if (tc.id) {
|
|
694
|
+
// New tool call with ID - store by ID
|
|
695
|
+
if (!toolCallsMap.has(tc.id)) {
|
|
696
|
+
toolCallsMap.set(tc.id, { ...tc })
|
|
697
|
+
}
|
|
698
|
+
} else if (tc.index !== undefined) {
|
|
699
|
+
// Continuation chunk (has index but no id) - find and merge arguments
|
|
700
|
+
// Find existing tool call by index
|
|
701
|
+
for (const [id, existing] of toolCallsMap) {
|
|
702
|
+
if (existing.index === tc.index && tc.function?.arguments) {
|
|
703
|
+
existing.function = existing.function || {}
|
|
704
|
+
existing.function.arguments = (existing.function.arguments || '') + tc.function.arguments
|
|
705
|
+
break
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (result.data.usage) usage = result.data.usage
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
reader.releaseLock()
|
|
717
|
+
|
|
718
|
+
// Convert Map to array
|
|
719
|
+
const toolCalls = Array.from(toolCallsMap.values())
|
|
720
|
+
|
|
721
|
+
return c.json({
|
|
722
|
+
id: state.id,
|
|
723
|
+
object: 'chat.completion',
|
|
724
|
+
created: state.created,
|
|
725
|
+
model: chatgptModel,
|
|
726
|
+
choices: [{
|
|
727
|
+
index: 0,
|
|
728
|
+
message: {
|
|
729
|
+
role: 'assistant',
|
|
730
|
+
content: fullContent || null,
|
|
731
|
+
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
|
732
|
+
},
|
|
733
|
+
finish_reason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
|
|
734
|
+
}],
|
|
735
|
+
usage: usage || { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
736
|
+
})
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
async function handleChatCompletion(c: Context) {
|
|
740
|
+
const body = await c.req.json()
|
|
741
|
+
const requestedModel = body.model || ''
|
|
742
|
+
const isStreaming = body.stream === true
|
|
743
|
+
|
|
744
|
+
if (isCursorKeyCheck(body)) {
|
|
745
|
+
logRequest('bypass', requestedModel, {})
|
|
746
|
+
logResponse(200)
|
|
747
|
+
return c.json(createCursorBypassResponse())
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const parsedKeys = parseRoutedKeys(c.req.header('authorization'))
|
|
751
|
+
if (parsedKeys.oauthError) {
|
|
752
|
+
return c.json({
|
|
753
|
+
error: {
|
|
754
|
+
message: parsedKeys.oauthError,
|
|
755
|
+
type: 'authentication_error',
|
|
756
|
+
code: 'authentication_error',
|
|
757
|
+
}
|
|
758
|
+
}, 401)
|
|
759
|
+
}
|
|
760
|
+
const oauthTokens = parsedKeys.oauth
|
|
761
|
+
let routing = resolveModelRouting(requestedModel, parsedKeys)
|
|
762
|
+
if (!routing && oauthTokens?.claudeToken) {
|
|
763
|
+
const resolvedModel = MODEL_ALIASES[requestedModel] || requestedModel
|
|
764
|
+
if (resolvedModel.startsWith('claude-')) {
|
|
765
|
+
routing = { claudeModel: resolvedModel, apiKey: oauthTokens.claudeToken }
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
const isClaude = routing !== null && (routing.claudeModel.startsWith('claude-') || parsedKeys.configs.some(c => c.mappings.some(m => m.from === requestedModel)))
|
|
769
|
+
|
|
770
|
+
// If not a Claude model and we have a default key, proxy to OpenAI or ChatGPT backend
|
|
771
|
+
if (!isClaude) {
|
|
772
|
+
if (parsedKeys.defaultKey) {
|
|
773
|
+
const tokenInfo: TokenInfo = {
|
|
774
|
+
token: parsedKeys.defaultKey,
|
|
775
|
+
accountId: parsedKeys.defaultAccountId,
|
|
776
|
+
}
|
|
777
|
+
const useChatGpt = Boolean(tokenInfo.accountId) || isJwtToken(tokenInfo.token)
|
|
778
|
+
if (useChatGpt) {
|
|
779
|
+
return handleChatGptProxy(c, body, requestedModel, tokenInfo, isStreaming)
|
|
780
|
+
}
|
|
781
|
+
return handleOpenAIProxy(c, body, requestedModel, tokenInfo.token, isStreaming)
|
|
782
|
+
}
|
|
783
|
+
if (oauthTokens?.chatgptToken) {
|
|
784
|
+
return handleChatGptProxy(c, body, requestedModel, {
|
|
785
|
+
token: oauthTokens.chatgptToken,
|
|
786
|
+
accountId: oauthTokens.chatgptAccountId,
|
|
787
|
+
}, isStreaming)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (!isClaude || !routing) {
|
|
792
|
+
logRequest('bypass', requestedModel, {})
|
|
793
|
+
const instructions = `Model "${requestedModel}" not configured. Set up routing in your API key:
|
|
794
|
+
|
|
795
|
+
Format: o3=opus-4.5,o3-mini=sonnet-4.5:sk-ant-xxx
|
|
796
|
+
|
|
797
|
+
Examples:
|
|
798
|
+
o3=opus-4.5:sk-ant-xxx # Single routing
|
|
799
|
+
o3=opus-4.5,o3-mini=sonnet-4.5:sk-ant-xxx # Multiple routings
|
|
800
|
+
sk-ant-xxx # Default key for claude-* models`
|
|
801
|
+
return c.json({
|
|
802
|
+
id: 'error', object: 'chat.completion',
|
|
803
|
+
choices: [{ index: 0, message: { role: 'assistant', content: instructions }, finish_reason: 'stop' }]
|
|
804
|
+
})
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const { claudeModel, apiKey: initialClaudeToken } = routing
|
|
808
|
+
const claudeRefreshToken = oauthTokens?.claudeRefreshToken
|
|
809
|
+
const usedOAuthClaude = Boolean(oauthTokens?.claudeToken && initialClaudeToken === oauthTokens.claudeToken)
|
|
810
|
+
let claudeAccessToken = initialClaudeToken
|
|
811
|
+
|
|
812
|
+
body.model = claudeModel
|
|
813
|
+
|
|
814
|
+
if (body.input !== undefined && !body.messages) {
|
|
815
|
+
if (typeof body.input === 'string') body.messages = [{ role: 'user', content: body.input }]
|
|
816
|
+
else if (Array.isArray(body.input)) body.messages = body.input
|
|
817
|
+
if (body.user && typeof body.user === 'string') body.messages = [{ role: 'system', content: body.user }, ...body.messages]
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const systemMessages = body.messages?.filter((msg: any) => msg.role === 'system') || []
|
|
821
|
+
body.messages = body.messages?.filter((msg: any) => msg.role !== 'system') || []
|
|
822
|
+
|
|
823
|
+
if (body.messages.length === 0) {
|
|
824
|
+
logError('No user messages in request')
|
|
825
|
+
return c.json({ error: 'No messages provided' }, 400)
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
body.system = [
|
|
829
|
+
{ type: 'text', text: "You are Claude Code, Anthropic's official CLI for Claude." },
|
|
830
|
+
{ type: 'text', text: "[Proxied via Sub Bridge - user's Claude subscription]" },
|
|
831
|
+
...systemMessages.map((msg: any) => ({ type: 'text', text: msg.content || '' })),
|
|
832
|
+
]
|
|
833
|
+
|
|
834
|
+
const contextSize = JSON.stringify(body.messages || []).length
|
|
835
|
+
const contextTokensEstimate = Math.ceil(contextSize / 4)
|
|
836
|
+
const systemText = body.system.map((s: any) => s.text).join('\n')
|
|
837
|
+
logRequest('claude', `${requestedModel} → ${claudeModel}`, {
|
|
838
|
+
system: systemText, messages: body.messages, tools: body.tools, tokens: contextTokensEstimate
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
body.max_tokens = claudeModel.includes('opus') ? 32_000 : 64_000
|
|
842
|
+
body.messages = convertMessages(body.messages)
|
|
843
|
+
|
|
844
|
+
if (body.tools?.length) {
|
|
845
|
+
body.tools = body.tools.map((tool: any, idx: number) => {
|
|
846
|
+
let converted: any
|
|
847
|
+
if (tool.type === 'function' && tool.function) {
|
|
848
|
+
converted = { name: tool.function.name, description: tool.function.description || '', input_schema: tool.function.parameters || { type: 'object', properties: {} } }
|
|
849
|
+
} else if (tool.name) {
|
|
850
|
+
converted = { name: tool.name, description: tool.description || '', input_schema: tool.input_schema || tool.parameters || { type: 'object', properties: {} } }
|
|
851
|
+
} else { converted = tool }
|
|
852
|
+
if (idx === body.tools.length - 1) converted.cache_control = { type: 'ephemeral' }
|
|
853
|
+
return converted
|
|
854
|
+
})
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (body.tool_choice === 'auto') body.tool_choice = { type: 'auto' }
|
|
858
|
+
else if (body.tool_choice === 'none' || body.tool_choice === null) delete body.tool_choice
|
|
859
|
+
else if (body.tool_choice === 'required') body.tool_choice = { type: 'any' }
|
|
860
|
+
else if (body.tool_choice?.function?.name) body.tool_choice = { type: 'tool', name: body.tool_choice.function.name }
|
|
861
|
+
|
|
862
|
+
if (body.system.length > 0) body.system[body.system.length - 1].cache_control = { type: 'ephemeral' }
|
|
863
|
+
|
|
864
|
+
const cleanBody: any = {}
|
|
865
|
+
const allowedFields = ['model', 'messages', 'max_tokens', 'stop_sequences', 'stream', 'system', 'temperature', 'top_p', 'top_k', 'tools', 'tool_choice']
|
|
866
|
+
for (const field of allowedFields) if (body[field] !== undefined) cleanBody[field] = body[field]
|
|
867
|
+
|
|
868
|
+
const sendClaudeRequest = (token: string) => fetch('https://api.anthropic.com/v1/messages', {
|
|
869
|
+
method: 'POST',
|
|
870
|
+
headers: {
|
|
871
|
+
'content-type': 'application/json',
|
|
872
|
+
'authorization': `Bearer ${token}`,
|
|
873
|
+
'anthropic-beta': 'oauth-2025-04-20,prompt-caching-2024-07-31',
|
|
874
|
+
'anthropic-version': '2023-06-01',
|
|
875
|
+
'accept': isStreaming ? 'text/event-stream' : 'application/json',
|
|
876
|
+
},
|
|
877
|
+
body: JSON.stringify(cleanBody),
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
let response = await sendClaudeRequest(claudeAccessToken)
|
|
881
|
+
if (!response.ok && response.status === 401 && usedOAuthClaude && claudeRefreshToken) {
|
|
882
|
+
try {
|
|
883
|
+
const refreshed = await refreshClaudeToken(claudeRefreshToken)
|
|
884
|
+
if (refreshed.accessToken) {
|
|
885
|
+
claudeAccessToken = refreshed.accessToken
|
|
886
|
+
response = await sendClaudeRequest(claudeAccessToken)
|
|
887
|
+
}
|
|
888
|
+
} catch {
|
|
889
|
+
return c.json({
|
|
890
|
+
error: {
|
|
891
|
+
message: 'Claude OAuth refresh failed. Please re-authenticate.',
|
|
892
|
+
type: 'authentication_error',
|
|
893
|
+
code: 'authentication_error',
|
|
894
|
+
}
|
|
895
|
+
}, 401)
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (!response.ok) {
|
|
900
|
+
const errorText = await response.text()
|
|
901
|
+
logError(errorText.slice(0, 200))
|
|
902
|
+
|
|
903
|
+
// Try to parse the Anthropic error and convert to OpenAI format
|
|
904
|
+
try {
|
|
905
|
+
const anthropicError = JSON.parse(errorText)
|
|
906
|
+
const errorMessage = anthropicError.error?.message || 'Unknown error'
|
|
907
|
+
const errorType = anthropicError.error?.type || 'api_error'
|
|
908
|
+
|
|
909
|
+
// Map Anthropic error types to user-friendly messages
|
|
910
|
+
let userMessage = errorMessage
|
|
911
|
+
if (errorType === 'rate_limit_error') {
|
|
912
|
+
userMessage = `Rate limit exceeded: ${errorMessage}`
|
|
913
|
+
} else if (errorType === 'authentication_error') {
|
|
914
|
+
userMessage = `Authentication failed: ${errorMessage}`
|
|
915
|
+
} else if (errorType === 'invalid_request_error') {
|
|
916
|
+
userMessage = `Invalid request: ${errorMessage}`
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Return OpenAI-compatible error format
|
|
920
|
+
const openAIError = {
|
|
921
|
+
error: {
|
|
922
|
+
message: userMessage,
|
|
923
|
+
type: errorType,
|
|
924
|
+
code: errorType,
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
return c.json(openAIError, response.status as any)
|
|
929
|
+
} catch (parseError) {
|
|
930
|
+
// If parsing fails, return raw error
|
|
931
|
+
return new Response(errorText, { status: response.status })
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
logResponse(response.status)
|
|
936
|
+
|
|
937
|
+
if (isStreaming) {
|
|
938
|
+
const reader = response.body!.getReader()
|
|
939
|
+
const decoder = new TextDecoder()
|
|
940
|
+
const converterState = createConverterState()
|
|
941
|
+
return stream(c, async (s) => {
|
|
942
|
+
while (true) {
|
|
943
|
+
const { done, value } = await reader.read()
|
|
944
|
+
if (done) break
|
|
945
|
+
const chunk = decoder.decode(value, { stream: true })
|
|
946
|
+
const results = processChunk(converterState, chunk, false)
|
|
947
|
+
for (const result of results) {
|
|
948
|
+
if (result.type === 'chunk') await s.write(`data: ${JSON.stringify(result.data)}\n\n`)
|
|
949
|
+
else if (result.type === 'done') await s.write('data: [DONE]\n\n')
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
reader.releaseLock()
|
|
953
|
+
})
|
|
954
|
+
} else {
|
|
955
|
+
const responseData = await response.json()
|
|
956
|
+
const openAIResponse = convertNonStreamingResponse(responseData as any)
|
|
957
|
+
return c.json(openAIResponse)
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
export function createChatRoutes() {
|
|
962
|
+
const app = new Hono()
|
|
963
|
+
|
|
964
|
+
// Models endpoint
|
|
965
|
+
app.get('/models', async (c) => {
|
|
966
|
+
const response = await fetch('https://models.dev/api.json')
|
|
967
|
+
if (!response.ok) return c.json({ object: 'list', data: [] })
|
|
968
|
+
const modelsData = await response.json() as any
|
|
969
|
+
const anthropicModels = modelsData.anthropic?.models || {}
|
|
970
|
+
const models = Object.entries(anthropicModels).map(([modelId, modelData]: [string, any]) => ({
|
|
971
|
+
id: modelId, object: 'model' as const,
|
|
972
|
+
created: Math.floor(new Date(modelData.release_date || '1970-01-01').getTime() / 1000),
|
|
973
|
+
owned_by: 'anthropic',
|
|
974
|
+
}))
|
|
975
|
+
return c.json({ object: 'list', data: models })
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
// Chat completions
|
|
979
|
+
app.post('/chat/completions', (c) => handleChatCompletion(c))
|
|
980
|
+
app.post('/messages', (c) => handleChatCompletion(c))
|
|
981
|
+
|
|
982
|
+
return app
|
|
983
|
+
}
|