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.
Files changed (163) hide show
  1. package/.cursor/commands/mcp-only.md +1 -0
  2. package/.github/workflows/npm-publish.yml +33 -0
  3. package/.github/workflows/pages.yml +40 -0
  4. package/.github/workflows/release-please.yml +21 -0
  5. package/.release-please-manifest.json +3 -0
  6. package/CHANGELOG.md +8 -0
  7. package/DEVELOPMENT.md +31 -0
  8. package/LICENSE +21 -0
  9. package/README.md +87 -0
  10. package/api/index.ts +12 -0
  11. package/bun.lock +102 -0
  12. package/dist/auth/oauth-flow.d.ts +24 -0
  13. package/dist/auth/oauth-flow.d.ts.map +1 -0
  14. package/dist/auth/oauth-flow.js +184 -0
  15. package/dist/auth/oauth-flow.js.map +1 -0
  16. package/dist/auth/oauth-manager.d.ts +13 -0
  17. package/dist/auth/oauth-manager.d.ts.map +1 -0
  18. package/dist/auth/oauth-manager.js +25 -0
  19. package/dist/auth/oauth-manager.js.map +1 -0
  20. package/dist/auth/provider.d.ts +42 -0
  21. package/dist/auth/provider.d.ts.map +1 -0
  22. package/dist/auth/provider.js +270 -0
  23. package/dist/auth/provider.js.map +1 -0
  24. package/dist/cli.d.ts +3 -0
  25. package/dist/cli.d.ts.map +1 -0
  26. package/dist/cli.js +91 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/mcp/proxy.d.ts +16 -0
  29. package/dist/mcp/proxy.d.ts.map +1 -0
  30. package/dist/mcp/proxy.js +85 -0
  31. package/dist/mcp/proxy.js.map +1 -0
  32. package/dist/mcp.d.ts +3 -0
  33. package/dist/mcp.d.ts.map +1 -0
  34. package/dist/mcp.js +50 -0
  35. package/dist/mcp.js.map +1 -0
  36. package/dist/routes/auth.d.ts +6 -0
  37. package/dist/routes/auth.d.ts.map +1 -0
  38. package/dist/routes/auth.js +149 -0
  39. package/dist/routes/auth.js.map +1 -0
  40. package/dist/routes/chat.d.ts +6 -0
  41. package/dist/routes/chat.d.ts.map +1 -0
  42. package/dist/routes/chat.js +808 -0
  43. package/dist/routes/chat.js.map +1 -0
  44. package/dist/routes/tunnels.d.ts +7 -0
  45. package/dist/routes/tunnels.d.ts.map +1 -0
  46. package/dist/routes/tunnels.js +44 -0
  47. package/dist/routes/tunnels.js.map +1 -0
  48. package/dist/server.d.ts +25 -0
  49. package/dist/server.d.ts.map +1 -0
  50. package/dist/server.js +157 -0
  51. package/dist/server.js.map +1 -0
  52. package/dist/tunnel/providers/cloudflare.d.ts +9 -0
  53. package/dist/tunnel/providers/cloudflare.d.ts.map +1 -0
  54. package/dist/tunnel/providers/cloudflare.js +47 -0
  55. package/dist/tunnel/providers/cloudflare.js.map +1 -0
  56. package/dist/tunnel/providers/index.d.ts +4 -0
  57. package/dist/tunnel/providers/index.d.ts.map +1 -0
  58. package/dist/tunnel/providers/index.js +13 -0
  59. package/dist/tunnel/providers/index.js.map +1 -0
  60. package/dist/tunnel/providers/ngrok.d.ts +10 -0
  61. package/dist/tunnel/providers/ngrok.d.ts.map +1 -0
  62. package/dist/tunnel/providers/ngrok.js +52 -0
  63. package/dist/tunnel/providers/ngrok.js.map +1 -0
  64. package/dist/tunnel/providers/tailscale.d.ts +10 -0
  65. package/dist/tunnel/providers/tailscale.d.ts.map +1 -0
  66. package/dist/tunnel/providers/tailscale.js +48 -0
  67. package/dist/tunnel/providers/tailscale.js.map +1 -0
  68. package/dist/tunnel/registry.d.ts +14 -0
  69. package/dist/tunnel/registry.d.ts.map +1 -0
  70. package/dist/tunnel/registry.js +86 -0
  71. package/dist/tunnel/registry.js.map +1 -0
  72. package/dist/tunnel/types.d.ts +26 -0
  73. package/dist/tunnel/types.d.ts.map +1 -0
  74. package/dist/tunnel/types.js +6 -0
  75. package/dist/tunnel/types.js.map +1 -0
  76. package/dist/tunnel/utils.d.ts +18 -0
  77. package/dist/tunnel/utils.d.ts.map +1 -0
  78. package/dist/tunnel/utils.js +57 -0
  79. package/dist/tunnel/utils.js.map +1 -0
  80. package/dist/types.d.ts +52 -0
  81. package/dist/types.d.ts.map +1 -0
  82. package/dist/types.js +4 -0
  83. package/dist/types.js.map +1 -0
  84. package/dist/utils/anthropic-to-openai-converter.d.ts +103 -0
  85. package/dist/utils/anthropic-to-openai-converter.d.ts.map +1 -0
  86. package/dist/utils/anthropic-to-openai-converter.js +376 -0
  87. package/dist/utils/anthropic-to-openai-converter.js.map +1 -0
  88. package/dist/utils/chat-to-responses.d.ts +59 -0
  89. package/dist/utils/chat-to-responses.d.ts.map +1 -0
  90. package/dist/utils/chat-to-responses.js +395 -0
  91. package/dist/utils/chat-to-responses.js.map +1 -0
  92. package/dist/utils/chatgpt-instructions.d.ts +3 -0
  93. package/dist/utils/chatgpt-instructions.d.ts.map +1 -0
  94. package/dist/utils/chatgpt-instructions.js +12 -0
  95. package/dist/utils/chatgpt-instructions.js.map +1 -0
  96. package/dist/utils/cli-args.d.ts +3 -0
  97. package/dist/utils/cli-args.d.ts.map +1 -0
  98. package/dist/utils/cli-args.js +10 -0
  99. package/dist/utils/cli-args.js.map +1 -0
  100. package/dist/utils/cors-bypass.d.ts +4 -0
  101. package/dist/utils/cors-bypass.d.ts.map +1 -0
  102. package/dist/utils/cors-bypass.js +30 -0
  103. package/dist/utils/cors-bypass.js.map +1 -0
  104. package/dist/utils/cursor-byok-bypass.d.ts +37 -0
  105. package/dist/utils/cursor-byok-bypass.d.ts.map +1 -0
  106. package/dist/utils/cursor-byok-bypass.js +53 -0
  107. package/dist/utils/cursor-byok-bypass.js.map +1 -0
  108. package/dist/utils/logger.d.ts +19 -0
  109. package/dist/utils/logger.d.ts.map +1 -0
  110. package/dist/utils/logger.js +192 -0
  111. package/dist/utils/logger.js.map +1 -0
  112. package/dist/utils/port.d.ts +27 -0
  113. package/dist/utils/port.d.ts.map +1 -0
  114. package/dist/utils/port.js +78 -0
  115. package/dist/utils/port.js.map +1 -0
  116. package/dist/utils/setup-instructions.d.ts +10 -0
  117. package/dist/utils/setup-instructions.d.ts.map +1 -0
  118. package/dist/utils/setup-instructions.js +49 -0
  119. package/dist/utils/setup-instructions.js.map +1 -0
  120. package/env.example +25 -0
  121. package/index.html +992 -0
  122. package/package.json +57 -0
  123. package/public/.nojekyll +0 -0
  124. package/public/assets/chat.png +0 -0
  125. package/public/assets/demo.gif +0 -0
  126. package/public/assets/demo.mp4 +0 -0
  127. package/public/assets/setup.png +0 -0
  128. package/public/assets/ui.png +0 -0
  129. package/public/index.html +292 -0
  130. package/release-please-config.json +10 -0
  131. package/src/auth/provider.ts +412 -0
  132. package/src/cli.ts +97 -0
  133. package/src/mcp/proxy.ts +64 -0
  134. package/src/mcp.ts +56 -0
  135. package/src/oauth/authorize.ts +270 -0
  136. package/src/oauth/crypto.ts +198 -0
  137. package/src/oauth/dcr.ts +129 -0
  138. package/src/oauth/metadata.ts +40 -0
  139. package/src/oauth/token.ts +173 -0
  140. package/src/routes/auth.ts +149 -0
  141. package/src/routes/chat.ts +983 -0
  142. package/src/routes/oauth.ts +220 -0
  143. package/src/routes/tunnels.ts +45 -0
  144. package/src/server.ts +204 -0
  145. package/src/tunnel/providers/cloudflare.ts +50 -0
  146. package/src/tunnel/providers/index.ts +7 -0
  147. package/src/tunnel/providers/ngrok.ts +56 -0
  148. package/src/tunnel/providers/tailscale.ts +50 -0
  149. package/src/tunnel/registry.ts +96 -0
  150. package/src/tunnel/types.ts +32 -0
  151. package/src/tunnel/utils.ts +59 -0
  152. package/src/types.ts +55 -0
  153. package/src/utils/anthropic-to-openai-converter.ts +578 -0
  154. package/src/utils/chat-to-responses.ts +512 -0
  155. package/src/utils/chatgpt-instructions.ts +7 -0
  156. package/src/utils/cli-args.ts +8 -0
  157. package/src/utils/cors-bypass.ts +39 -0
  158. package/src/utils/cursor-byok-bypass.ts +56 -0
  159. package/src/utils/logger.ts +174 -0
  160. package/src/utils/port.ts +99 -0
  161. package/src/utils/setup-instructions.ts +59 -0
  162. package/tsconfig.json +22 -0
  163. 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
+ }