chenbhao commited on
Commit
2d01c57
·
1 Parent(s): 7d2eb5a

fix: tui can recevie message and auto load gateway

Browse files
src/components/PromptInput/PromptInputFooter.tsx CHANGED
@@ -10,6 +10,7 @@ import { useSettings } from '../../hooks/useSettings.js';
10
  import { useTerminalSize } from '../../hooks/useTerminalSize.js';
11
  import { Box, Text } from '../../ink.js';
12
  import type { MCPServerConnection } from '../../services/mcp/types.js';
 
13
  import { useAppState } from '../../state/AppState.js';
14
  import type { ToolPermissionContext } from '../../Tool.js';
15
  import type { Message } from '../../types/message.js';
@@ -144,6 +145,7 @@ function PromptInputFooter({
144
  <Box flexShrink={1} gap={1}>
145
  {isFullscreen ? null : <Notifications apiKeyStatus={apiKeyStatus} autoUpdaterResult={autoUpdaterResult} debug={debug} isAutoUpdating={isAutoUpdating} verbose={verbose} messages={messages} onAutoUpdaterResult={onAutoUpdaterResult} onChangeIsUpdating={onChangeIsUpdating} ideSelection={ideSelection} mcpClients={mcpClients} isInputWrapped={isInputWrapped} isNarrow={isNarrow} />}
146
  {"external" === 'ant' && isUndercover() && <Text dimColor>undercover</Text>}
 
147
  <BridgeStatusIndicator bridgeSelected={bridgeSelected} />
148
  </Box>
149
  </Box>
@@ -151,6 +153,23 @@ function PromptInputFooter({
151
  </>;
152
  }
153
  export default memo(PromptInputFooter);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  type BridgeStatusProps = {
155
  bridgeSelected: boolean;
156
  };
 
10
  import { useTerminalSize } from '../../hooks/useTerminalSize.js';
11
  import { Box, Text } from '../../ink.js';
12
  import type { MCPServerConnection } from '../../services/mcp/types.js';
13
+ import { telegramService } from '../../services/telegram/TelegramService.js';
14
  import { useAppState } from '../../state/AppState.js';
15
  import type { ToolPermissionContext } from '../../Tool.js';
16
  import type { Message } from '../../types/message.js';
 
145
  <Box flexShrink={1} gap={1}>
146
  {isFullscreen ? null : <Notifications apiKeyStatus={apiKeyStatus} autoUpdaterResult={autoUpdaterResult} debug={debug} isAutoUpdating={isAutoUpdating} verbose={verbose} messages={messages} onAutoUpdaterResult={onAutoUpdaterResult} onChangeIsUpdating={onChangeIsUpdating} ideSelection={ideSelection} mcpClients={mcpClients} isInputWrapped={isInputWrapped} isNarrow={isNarrow} />}
147
  {"external" === 'ant' && isUndercover() && <Text dimColor>undercover</Text>}
148
+ <TelegramStatusIndicator />
149
  <BridgeStatusIndicator bridgeSelected={bridgeSelected} />
150
  </Box>
151
  </Box>
 
153
  </>;
154
  }
155
  export default memo(PromptInputFooter);
156
+
157
+ function TelegramStatusIndicator(): React.ReactNode {
158
+ const state = React.useSyncExternalStore(
159
+ telegramService.subscribe,
160
+ telegramService.getStateSnapshot,
161
+ telegramService.getStateSnapshot,
162
+ );
163
+
164
+ if (state.status !== 'running' && state.status !== 'starting') {
165
+ return null;
166
+ }
167
+
168
+ return <Text color={state.status === 'running' ? 'notice' : 'warning'}>
169
+
170
+ </Text>;
171
+ }
172
+
173
  type BridgeStatusProps = {
174
  bridgeSelected: boolean;
175
  };
src/hooks/useTelegramBridge.ts CHANGED
@@ -1,49 +1,132 @@
1
  import { useEffect, useRef } from 'react'
 
2
  import { telegramService } from '../services/telegram/TelegramService.js'
3
  import {
4
  TELEGRAM_CHANNEL_SERVER,
5
  type TelegramInboundEvent,
6
  } from '../services/telegram/telegramTypes.js'
 
 
 
 
 
 
7
  import type { Message } from '../types/message.js'
8
  import { enqueue } from '../utils/messageQueueManager.js'
9
  import { getContentText } from '../utils/messages.js'
10
- import { logForDebugging } from '../utils/debug.js'
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  type Props = {
13
  messages: Message[]
14
  isLoading: boolean
 
15
  }
16
 
17
  type ActiveTelegramTurn = {
18
  chatId: string
19
- lastAssistantText?: string
 
 
 
 
20
  }
21
 
22
- export function useTelegramBridge({ messages, isLoading }: Props): void {
23
  const pendingInboundRef = useRef<TelegramInboundEvent[]>([])
24
  const activeTurnRef = useRef<ActiveTelegramTurn | null>(null)
25
  const lastProcessedMessageCountRef = useRef(messages.length)
26
  const previousLoadingRef = useRef(isLoading)
 
27
 
28
  useEffect(() => {
29
- return telegramService.subscribeToInbound(event => {
30
- pendingInboundRef.current.push(event)
31
- enqueue({
32
- value: event.text,
33
- mode: 'prompt',
34
- skipSlashCommands: true,
35
- origin: {
36
- kind: 'channel',
37
- server: TELEGRAM_CHANNEL_SERVER,
38
- } as const,
39
- })
40
  })
41
  }, [])
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  useEffect(() => {
44
  const newMessages = messages.slice(lastProcessedMessageCountRef.current)
45
 
 
 
46
  for (const message of newMessages) {
 
 
47
  if (
48
  message.type === 'user' &&
49
  message.origin?.kind === 'channel' &&
@@ -53,15 +136,32 @@ export function useTelegramBridge({ messages, isLoading }: Props): void {
53
  if (inbound) {
54
  activeTurnRef.current = {
55
  chatId: inbound.chatId,
 
56
  }
 
57
  }
58
  continue
59
  }
60
 
61
  if (message.type === 'assistant' && activeTurnRef.current) {
62
  const text = getContentText(message.message.content)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  if (text) {
64
- activeTurnRef.current.lastAssistantText = text
65
  }
66
  }
67
  }
@@ -73,24 +173,33 @@ export function useTelegramBridge({ messages, isLoading }: Props): void {
73
  const wasLoading = previousLoadingRef.current
74
  previousLoadingRef.current = isLoading
75
 
 
 
76
  if (!wasLoading || isLoading || !activeTurnRef.current) {
77
  return
78
  }
79
 
80
- const { chatId, lastAssistantText } = activeTurnRef.current
 
 
 
 
 
 
 
81
  activeTurnRef.current = null
82
 
83
  void telegramService
84
  .sendMessage(
85
  chatId,
86
- lastAssistantText ?? '这一轮没有可回传的文本结果,请查看本地终端会话。',
87
  )
88
  .catch(error => {
89
- logForDebugging(
90
  `[telegram] failed to send outbound reply: ${
91
  error instanceof Error ? error.message : String(error)
92
  }`,
93
- { level: 'error' },
94
  )
95
  })
96
  }, [isLoading])
 
1
  import { useEffect, useRef } from 'react'
2
+ import type { AppStateStore } from '../state/AppState.js'
3
  import { telegramService } from '../services/telegram/TelegramService.js'
4
  import {
5
  TELEGRAM_CHANNEL_SERVER,
6
  type TelegramInboundEvent,
7
  } from '../services/telegram/telegramTypes.js'
8
+ import {
9
+ handleTelegramCallback,
10
+ logTelegramInteractiveError,
11
+ maybeHandleTelegramInteractiveInput,
12
+ } from '../services/telegram/interactiveCommands.js'
13
+ import { hasTelegramRuntimeConfig } from '../services/telegram/telegramConfig.js'
14
  import type { Message } from '../types/message.js'
15
  import { enqueue } from '../utils/messageQueueManager.js'
16
  import { getContentText } from '../utils/messages.js'
17
+
18
+ // 日志函数 - 只写入文件
19
+ function logTelegramDebug(message: string, level: 'debug' | 'error' | 'info' = 'debug'): void {
20
+ try {
21
+ const fs = require('node:fs')
22
+ const path = require('node:path')
23
+ const LOG_FILE_PATH = path.join(process.cwd(), 'log.md')
24
+ const timestamp = new Date().toISOString()
25
+ const logEntry = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`
26
+ fs.appendFileSync(LOG_FILE_PATH, logEntry, 'utf-8')
27
+ } catch (error) {
28
+ // 忽略文件写入错误
29
+ }
30
+ }
31
 
32
  type Props = {
33
  messages: Message[]
34
  isLoading: boolean
35
+ store: AppStateStore
36
  }
37
 
38
  type ActiveTelegramTurn = {
39
  chatId: string
40
+ responseParts: string[]
41
+ }
42
+
43
+ function stripXmlTags(text: string): string {
44
+ return text.replace(/<[^>]+>/g, '').trim()
45
  }
46
 
47
+ export function useTelegramBridge({ messages, isLoading, store }: Props): void {
48
  const pendingInboundRef = useRef<TelegramInboundEvent[]>([])
49
  const activeTurnRef = useRef<ActiveTelegramTurn | null>(null)
50
  const lastProcessedMessageCountRef = useRef(messages.length)
51
  const previousLoadingRef = useRef(isLoading)
52
+ const autoStartAttemptedRef = useRef(false)
53
 
54
  useEffect(() => {
55
+ if (autoStartAttemptedRef.current) return
56
+ autoStartAttemptedRef.current = true
57
+ if (!hasTelegramRuntimeConfig()) return
58
+
59
+ void telegramService.startFromSavedConfig().catch(error => {
60
+ logTelegramDebug(`[telegram] auto-start failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
 
 
 
 
 
61
  })
62
  }, [])
63
 
64
+ useEffect(() => {
65
+ return telegramService.subscribeToInbound(event => {
66
+ void (async () => {
67
+ try {
68
+ logTelegramDebug(`[telegram] received inbound event: chatId=${event.chatId}, text=${event.text.slice(0, 50)}`)
69
+
70
+ if (await maybeHandleTelegramInteractiveInput(event, store)) {
71
+ return
72
+ }
73
+
74
+ pendingInboundRef.current.push(event)
75
+ logTelegramDebug(`[telegram] added to pendingInboundRef: chatId=${event.chatId}`)
76
+
77
+ enqueue({
78
+ value: event.text,
79
+ mode: 'prompt',
80
+ skipSlashCommands: true,
81
+ bridgeOrigin: true,
82
+ origin: {
83
+ kind: 'channel',
84
+ server: TELEGRAM_CHANNEL_SERVER,
85
+ } as const,
86
+ })
87
+ } catch (error) {
88
+ logTelegramInteractiveError(error)
89
+ await telegramService.sendMessage(
90
+ event.chatId,
91
+ `Telegram 交互处理失败: ${
92
+ error instanceof Error ? error.message : String(error)
93
+ }`,
94
+ ).catch(() => {})
95
+ }
96
+ })()
97
+ })
98
+ }, [store])
99
+
100
+ useEffect(() => {
101
+ return telegramService.subscribeToCallbacks(event => {
102
+ void (async () => {
103
+ try {
104
+ await handleTelegramCallback(event, store)
105
+ } catch (error) {
106
+ logTelegramInteractiveError(error)
107
+ await telegramService.answerCallbackQuery(
108
+ event.callbackQueryId,
109
+ '处理按钮操作失败',
110
+ ).catch(() => {})
111
+ await telegramService.sendMessage(
112
+ event.chatId,
113
+ `Telegram 按钮操作失败: ${
114
+ error instanceof Error ? error.message : String(error)
115
+ }`,
116
+ ).catch(() => {})
117
+ }
118
+ })()
119
+ })
120
+ }, [store])
121
+
122
  useEffect(() => {
123
  const newMessages = messages.slice(lastProcessedMessageCountRef.current)
124
 
125
+ logTelegramDebug(`[telegram] processing ${newMessages.length} new messages, activeTurn: ${activeTurnRef.current ? 'active' : 'null'}`)
126
+
127
  for (const message of newMessages) {
128
+ logTelegramDebug(`[telegram] processing message: type=${message.type}, hasOrigin=!!${message.origin}, origin=${JSON.stringify(message.origin)}`)
129
+
130
  if (
131
  message.type === 'user' &&
132
  message.origin?.kind === 'channel' &&
 
136
  if (inbound) {
137
  activeTurnRef.current = {
138
  chatId: inbound.chatId,
139
+ responseParts: [],
140
  }
141
+ logTelegramDebug(`[telegram] active turn started for chatId: ${inbound.chatId}`)
142
  }
143
  continue
144
  }
145
 
146
  if (message.type === 'assistant' && activeTurnRef.current) {
147
  const text = getContentText(message.message.content)
148
+ logTelegramDebug(`[telegram] assistant message, text length: ${text?.length || 0}, content type: ${typeof message.message.content}`)
149
+ if (text) {
150
+ activeTurnRef.current.responseParts.push(text)
151
+ logTelegramDebug(`[telegram] added to responseParts, total parts: ${activeTurnRef.current.responseParts.length}`)
152
+ }
153
+ continue
154
+ }
155
+
156
+ if (
157
+ message.type === 'system' &&
158
+ message.subtype === 'local_command' &&
159
+ activeTurnRef.current
160
+ ) {
161
+ const text = stripXmlTags(message.content)
162
+ logTelegramDebug(`[telegram] local_command, text length: ${text?.length || 0}`)
163
  if (text) {
164
+ activeTurnRef.current.responseParts.push(text)
165
  }
166
  }
167
  }
 
173
  const wasLoading = previousLoadingRef.current
174
  previousLoadingRef.current = isLoading
175
 
176
+ logTelegramDebug(`[telegram] loading state changed: wasLoading=${wasLoading}, isLoading=${isLoading}, hasActiveTurn=!!${activeTurnRef.current}`)
177
+
178
  if (!wasLoading || isLoading || !activeTurnRef.current) {
179
  return
180
  }
181
 
182
+ const { chatId, responseParts } = activeTurnRef.current
183
+ const messageToSend = responseParts.join('\n\n').trim() ||
184
+ '这一轮没有可回传的文本结果,请查看本地终端会话。'
185
+
186
+ logTelegramDebug(`[telegram] sending message to chatId: ${chatId}, parts count: ${responseParts.length}, total length: ${messageToSend.length}`)
187
+ logTelegramDebug(`[telegram] message content: ${JSON.stringify(messageToSend)}`)
188
+ logTelegramDebug(`[telegram] response parts: ${JSON.stringify(responseParts)}`)
189
+
190
  activeTurnRef.current = null
191
 
192
  void telegramService
193
  .sendMessage(
194
  chatId,
195
+ messageToSend,
196
  )
197
  .catch(error => {
198
+ logTelegramDebug(
199
  `[telegram] failed to send outbound reply: ${
200
  error instanceof Error ? error.message : String(error)
201
  }`,
202
+ 'error',
203
  )
204
  })
205
  }, [isLoading])
src/screens/REPL.tsx CHANGED
@@ -4049,7 +4049,8 @@ export function REPL({
4049
 
4050
  useTelegramBridge({
4051
  messages,
4052
- isLoading
 
4053
  });
4054
 
4055
  // Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List)
 
4049
 
4050
  useTelegramBridge({
4051
  messages,
4052
+ isLoading,
4053
+ store
4054
  });
4055
 
4056
  // Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List)
src/services/api/copilotClient.ts ADDED
@@ -0,0 +1,1150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getGlobalConfig, saveGlobalConfig, type ConnectedProviderInfo } from '../../utils/config.js'
2
+
3
+ const COPILOT_API_BASE = 'https://api.githubcopilot.com'
4
+ const MODELS_DEV_URL = 'https://models.dev/api.json'
5
+
6
+ export type CopilotModelInfo = {
7
+ id: string
8
+ label: string
9
+ description: string
10
+ supportedEndpoints?: string[]
11
+ }
12
+
13
+ type CopilotOutputTokenParam = 'max_tokens' | 'max_completion_tokens'
14
+ type CopilotCompatibilityInfo = {
15
+ outputTokenParam?: CopilotOutputTokenParam
16
+ modelSupported?: boolean
17
+ chatCompletionsSupported?: boolean
18
+ updatedAt: number
19
+ }
20
+
21
+ const COPILOT_COMPATIBILITY_CACHE_TTL_MS = 24 * 60 * 60 * 1000
22
+ const COPILOT_CHAT_COMPLETIONS_ENDPOINT = '/chat/completions'
23
+
24
+ export function isCopilotModel(model: string): boolean {
25
+ return model.startsWith('copilot:')
26
+ }
27
+
28
+ export function getCopilotModelId(model: string): string {
29
+ return model.replace(/^copilot:/, '')
30
+ }
31
+
32
+ export function getCopilotProvider(): ConnectedProviderInfo | undefined {
33
+ const config = getGlobalConfig()
34
+ return config.connectedProviders?.['github-copilot']
35
+ }
36
+
37
+ export function isCopilotConnected(): boolean {
38
+ return !!getCopilotProvider()?.oauthToken
39
+ }
40
+
41
+ const FALLBACK_COPILOT_MODELS: CopilotModelInfo[] = [
42
+ { id: 'copilot:claude-sonnet-4.6', label: 'Claude Sonnet 4.6', description: 'Claude Sonnet 4.6 via Copilot' },
43
+ { id: 'copilot:claude-sonnet-4.5', label: 'Claude Sonnet 4.5', description: 'Claude Sonnet 4.5 via Copilot' },
44
+ { id: 'copilot:claude-sonnet-4', label: 'Claude Sonnet 4', description: 'Claude Sonnet 4 via Copilot' },
45
+ { id: 'copilot:claude-opus-4.6', label: 'Claude Opus 4.6', description: 'Claude Opus 4.6 via Copilot' },
46
+ { id: 'copilot:claude-opus-4.5', label: 'Claude Opus 4.5', description: 'Claude Opus 4.5 via Copilot' },
47
+ { id: 'copilot:claude-opus-41', label: 'Claude Opus 4.1', description: 'Claude Opus 4.1 via Copilot' },
48
+ { id: 'copilot:claude-haiku-4.5', label: 'Claude Haiku 4.5', description: 'Claude Haiku 4.5 via Copilot' },
49
+ { id: 'copilot:gpt-5.4', label: 'GPT-5.4', description: 'OpenAI GPT-5.4 via Copilot' },
50
+ { id: 'copilot:gpt-5.4-mini', label: 'GPT-5.4 mini', description: 'OpenAI GPT-5.4 mini via Copilot' },
51
+ { id: 'copilot:gpt-5.3-codex', label: 'GPT-5.3-Codex', description: 'OpenAI GPT-5.3-Codex via Copilot' },
52
+ { id: 'copilot:gpt-5.2-codex', label: 'GPT-5.2-Codex', description: 'OpenAI GPT-5.2-Codex via Copilot' },
53
+ { id: 'copilot:gpt-5.2', label: 'GPT-5.2', description: 'OpenAI GPT-5.2 via Copilot' },
54
+ { id: 'copilot:gpt-5.1', label: 'GPT-5.1', description: 'OpenAI GPT-5.1 via Copilot' },
55
+ { id: 'copilot:gpt-5.1-codex', label: 'GPT-5.1-Codex', description: 'OpenAI GPT-5.1-Codex via Copilot' },
56
+ { id: 'copilot:gpt-5.1-codex-mini', label: 'GPT-5.1-Codex-mini', description: 'OpenAI GPT-5.1-Codex-mini via Copilot' },
57
+ { id: 'copilot:gpt-5.1-codex-max', label: 'GPT-5.1-Codex-max', description: 'OpenAI GPT-5.1-Codex-max via Copilot' },
58
+ { id: 'copilot:gpt-5', label: 'GPT-5', description: 'OpenAI GPT-5 via Copilot' },
59
+ { id: 'copilot:gpt-5-mini', label: 'GPT-5-mini', description: 'OpenAI GPT-5-mini via Copilot' },
60
+ { id: 'copilot:gpt-4.1', label: 'GPT-4.1', description: 'OpenAI GPT-4.1 via Copilot' },
61
+ { id: 'copilot:gpt-4o', label: 'GPT-4o', description: 'OpenAI GPT-4o via Copilot' },
62
+ { id: 'copilot:gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview', description: 'Google Gemini 3.1 Pro via Copilot' },
63
+ { id: 'copilot:gemini-3-pro-preview', label: 'Gemini 3 Pro Preview', description: 'Google Gemini 3 Pro via Copilot' },
64
+ { id: 'copilot:gemini-3-flash-preview', label: 'Gemini 3 Flash', description: 'Google Gemini 3 Flash via Copilot' },
65
+ { id: 'copilot:gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Google Gemini 2.5 Pro via Copilot' },
66
+ { id: 'copilot:grok-code-fast-1', label: 'Grok Code Fast 1', description: 'xAI Grok Code Fast 1 via Copilot' },
67
+ ]
68
+
69
+ let cachedModels: CopilotModelInfo[] | null = null
70
+ let copilotModelsRefreshPromise: Promise<void> | null = null
71
+
72
+ type CopilotApiModel = {
73
+ id: string
74
+ name?: string
75
+ model_picker_enabled?: boolean
76
+ policy?: { state?: string }
77
+ supported_endpoints?: string[]
78
+ }
79
+
80
+ function supportsCopilotChatCompletions(model: CopilotModelInfo): boolean {
81
+ return (
82
+ !model.supportedEndpoints ||
83
+ model.supportedEndpoints.includes(COPILOT_CHAT_COMPLETIONS_ENDPOINT)
84
+ )
85
+ }
86
+
87
+ export async function fetchCopilotModelsFromApi(): Promise<CopilotModelInfo[] | null> {
88
+ const provider = getCopilotProvider()
89
+ if (!provider?.oauthToken) return null
90
+
91
+ const response = await fetch(`${COPILOT_API_BASE}/models`, {
92
+ headers: {
93
+ Authorization: `Bearer ${provider.oauthToken}`,
94
+ 'User-Agent': 'claude-code/2.1.88',
95
+ 'Openai-Intent': 'conversation-edits',
96
+ 'x-initiator': 'user',
97
+ },
98
+ signal: AbortSignal.timeout(10_000),
99
+ })
100
+ if (!response.ok) {
101
+ throw new Error(`Copilot models API failed (${response.status})`)
102
+ }
103
+
104
+ const data = await response.json() as {
105
+ data?: CopilotApiModel[]
106
+ }
107
+ if (!Array.isArray(data.data) || data.data.length === 0) {
108
+ return null
109
+ }
110
+
111
+ const models = data.data
112
+ .filter(model => model.model_picker_enabled !== false)
113
+ .filter(model => model.policy?.state !== 'disabled')
114
+ .map(
115
+ (model): CopilotModelInfo => ({
116
+ id: `copilot:${model.id}`,
117
+ label: model.name || model.id,
118
+ description: `${model.name || model.id} via Copilot`,
119
+ supportedEndpoints: model.supported_endpoints,
120
+ }),
121
+ )
122
+
123
+ return models.length > 0 ? models : null
124
+ }
125
+
126
+ export async function fetchCopilotModelsFromModelsDev(): Promise<CopilotModelInfo[]> {
127
+ try {
128
+ const response = await fetch(MODELS_DEV_URL, {
129
+ signal: AbortSignal.timeout(10_000),
130
+ })
131
+ if (!response.ok) return FALLBACK_COPILOT_MODELS
132
+
133
+ const data = await response.json() as Record<string, { models?: Record<string, { name?: string }> }>
134
+ const copilotProvider = data['github-copilot']
135
+ if (!copilotProvider?.models) return FALLBACK_COPILOT_MODELS
136
+
137
+ const models: CopilotModelInfo[] = Object.entries(copilotProvider.models).map(
138
+ ([modelId, info]) => ({
139
+ id: `copilot:${modelId}`,
140
+ label: info.name || modelId,
141
+ description: `${info.name || modelId} via Copilot`,
142
+ }),
143
+ )
144
+
145
+ return models.length > 0 ? models : FALLBACK_COPILOT_MODELS
146
+ } catch {
147
+ return FALLBACK_COPILOT_MODELS
148
+ }
149
+ }
150
+
151
+ export async function fetchCopilotModels(): Promise<CopilotModelInfo[]> {
152
+ try {
153
+ const apiModels = await fetchCopilotModelsFromApi()
154
+ if (apiModels && apiModels.length > 0) {
155
+ return apiModels
156
+ }
157
+ } catch {}
158
+
159
+ return fetchCopilotModelsFromModelsDev()
160
+ }
161
+
162
+ function hasCopilotEndpointMetadata(models: CopilotModelInfo[]): boolean {
163
+ return models.some(model => Array.isArray(model.supportedEndpoints))
164
+ }
165
+
166
+ function shouldUseCachedCopilotModels(cache: {
167
+ models: CopilotModelInfo[]
168
+ fetchedAt: number
169
+ } | null | undefined): boolean {
170
+ if (!cache || cache.models.length === 0) return false
171
+ if (!hasCopilotEndpointMetadata(cache.models)) return false
172
+ return Date.now() - cache.fetchedAt < 3600_000
173
+ }
174
+
175
+ function refreshCopilotModelsCacheInBackground(): void {
176
+ if (copilotModelsRefreshPromise || !isCopilotConnected()) {
177
+ return
178
+ }
179
+ copilotModelsRefreshPromise = (async () => {
180
+ const models = await fetchCopilotModels()
181
+ cachedModels = models
182
+ saveGlobalConfig(current => ({
183
+ ...current,
184
+ copilotModelsCache: { models, fetchedAt: Date.now() },
185
+ }))
186
+ })()
187
+ .catch(() => {})
188
+ .finally(() => {
189
+ copilotModelsRefreshPromise = null
190
+ })
191
+ }
192
+
193
+ export async function getCopilotModels(): Promise<CopilotModelInfo[]> {
194
+ if (cachedModels) return filterUnavailableCopilotModels(cachedModels)
195
+
196
+ const config = getGlobalConfig()
197
+ const cached = config.copilotModelsCache
198
+ if (shouldUseCachedCopilotModels(cached)) {
199
+ cachedModels = cached.models
200
+ return filterUnavailableCopilotModels(cachedModels)
201
+ }
202
+
203
+ const models = await fetchCopilotModels()
204
+ cachedModels = models
205
+
206
+ saveGlobalConfig(current => ({
207
+ ...current,
208
+ copilotModelsCache: { models, fetchedAt: Date.now() },
209
+ }))
210
+
211
+ return filterUnavailableCopilotModels(models)
212
+ }
213
+
214
+ export function getCopilotModelsCached(): CopilotModelInfo[] {
215
+ if (cachedModels) {
216
+ if (!hasCopilotEndpointMetadata(cachedModels)) {
217
+ refreshCopilotModelsCacheInBackground()
218
+ }
219
+ return filterUnavailableCopilotModels(cachedModels)
220
+ }
221
+
222
+ const config = getGlobalConfig()
223
+ const cached = config.copilotModelsCache
224
+ if (cached && cached.models.length > 0) {
225
+ cachedModels = cached.models
226
+ if (!shouldUseCachedCopilotModels(cached)) {
227
+ refreshCopilotModelsCacheInBackground()
228
+ }
229
+ return filterUnavailableCopilotModels(cachedModels)
230
+ }
231
+
232
+ refreshCopilotModelsCacheInBackground()
233
+ return filterUnavailableCopilotModels(FALLBACK_COPILOT_MODELS)
234
+ }
235
+
236
+ export { FALLBACK_COPILOT_MODELS as COPILOT_MODELS }
237
+
238
+ function getCachedCopilotCompatibility(
239
+ modelId: string,
240
+ ): CopilotCompatibilityInfo | undefined {
241
+ const compatibility = getGlobalConfig().copilotCompatibilityCache?.[modelId]
242
+ if (!compatibility) return undefined
243
+ if (Date.now() - compatibility.updatedAt > COPILOT_COMPATIBILITY_CACHE_TTL_MS) {
244
+ return undefined
245
+ }
246
+ return compatibility
247
+ }
248
+
249
+ function filterUnavailableCopilotModels(
250
+ models: CopilotModelInfo[],
251
+ ): CopilotModelInfo[] {
252
+ return models.filter(model => {
253
+ if (!supportsCopilotChatCompletions(model)) {
254
+ return false
255
+ }
256
+ const compatibility = getCachedCopilotCompatibility(
257
+ getCopilotModelId(model.id),
258
+ )
259
+ return (
260
+ compatibility?.modelSupported !== false &&
261
+ compatibility?.chatCompletionsSupported !== false
262
+ )
263
+ })
264
+ }
265
+
266
+ function saveCopilotCompatibility(
267
+ modelId: string,
268
+ updates: Partial<CopilotCompatibilityInfo>,
269
+ ): void {
270
+ saveGlobalConfig(current => {
271
+ const existing = current.copilotCompatibilityCache?.[modelId]
272
+ const next = {
273
+ ...existing,
274
+ ...updates,
275
+ updatedAt: Date.now(),
276
+ } satisfies CopilotCompatibilityInfo
277
+
278
+ if (
279
+ existing?.outputTokenParam === next.outputTokenParam &&
280
+ existing?.modelSupported === next.modelSupported &&
281
+ existing?.chatCompletionsSupported === next.chatCompletionsSupported
282
+ ) {
283
+ return current
284
+ }
285
+
286
+ return {
287
+ ...current,
288
+ copilotCompatibilityCache: {
289
+ ...current.copilotCompatibilityCache,
290
+ [modelId]: next,
291
+ },
292
+ }
293
+ })
294
+ }
295
+
296
+ function getPreferredCopilotOutputTokenParam(
297
+ modelId: string,
298
+ ): CopilotOutputTokenParam {
299
+ return (
300
+ getCachedCopilotCompatibility(modelId)?.outputTokenParam ??
301
+ 'max_tokens'
302
+ )
303
+ }
304
+
305
+ function savePreferredCopilotOutputTokenParam(
306
+ modelId: string,
307
+ outputTokenParam: CopilotOutputTokenParam,
308
+ ): void {
309
+ saveCopilotCompatibility(modelId, { outputTokenParam })
310
+ }
311
+
312
+ function buildCopilotChatRequestBody(params: {
313
+ modelId: string
314
+ messages: OpenAIMessage[]
315
+ isStreaming: boolean
316
+ maxTokens?: number
317
+ tools?: OpenAITool[]
318
+ outputTokenParam: CopilotOutputTokenParam
319
+ }): Record<string, unknown> {
320
+ const requestBody: Record<string, unknown> = {
321
+ model: params.modelId,
322
+ messages: params.messages,
323
+ stream: params.isStreaming,
324
+ }
325
+
326
+ if (params.maxTokens) {
327
+ requestBody[params.outputTokenParam] = params.maxTokens
328
+ }
329
+
330
+ if (params.tools && params.tools.length > 0) {
331
+ requestBody.tools = params.tools
332
+ requestBody.tool_choice = 'auto'
333
+ }
334
+
335
+ return requestBody
336
+ }
337
+
338
+ async function createCopilotErrorInfo(response: Response): Promise<{
339
+ message?: string
340
+ code?: string
341
+ }> {
342
+ const text = await response
343
+ .clone()
344
+ .text()
345
+ .catch(() => '')
346
+ if (!text) return {}
347
+ try {
348
+ const parsed = JSON.parse(text) as {
349
+ error?: { message?: string; code?: string }
350
+ }
351
+ return {
352
+ message: parsed.error?.message,
353
+ code: parsed.error?.code,
354
+ }
355
+ } catch {
356
+ return {}
357
+ }
358
+ }
359
+
360
+ function createCachedCopilotErrorResponse(
361
+ message: string,
362
+ code: string,
363
+ ): Response {
364
+ return new Response(JSON.stringify({ error: { message, code } }), {
365
+ status: 400,
366
+ headers: { 'Content-Type': 'application/json' },
367
+ })
368
+ }
369
+
370
+ function getSuggestedCopilotOutputTokenParam(
371
+ currentParam: CopilotOutputTokenParam,
372
+ errorMessage: string | undefined,
373
+ ): CopilotOutputTokenParam | undefined {
374
+ if (!errorMessage) return undefined
375
+ if (
376
+ currentParam === 'max_tokens' &&
377
+ errorMessage.includes("Use 'max_completion_tokens' instead")
378
+ ) {
379
+ return 'max_completion_tokens'
380
+ }
381
+ if (
382
+ currentParam === 'max_completion_tokens' &&
383
+ errorMessage.includes("Use 'max_tokens' instead")
384
+ ) {
385
+ return 'max_tokens'
386
+ }
387
+ return undefined
388
+ }
389
+
390
+ async function postCopilotChatCompletion(params: {
391
+ oauthToken: string
392
+ requestBody: Record<string, unknown>
393
+ signal?: AbortSignal
394
+ }): Promise<Response> {
395
+ return fetch(`${COPILOT_API_BASE}/chat/completions`, {
396
+ method: 'POST',
397
+ headers: {
398
+ 'Content-Type': 'application/json',
399
+ Authorization: `Bearer ${params.oauthToken}`,
400
+ 'User-Agent': 'claude-code/2.1.88',
401
+ 'Openai-Intent': 'conversation-edits',
402
+ 'x-initiator': 'user',
403
+ },
404
+ body: JSON.stringify(params.requestBody),
405
+ signal: params.signal,
406
+ })
407
+ }
408
+
409
+ function saveCopilotCompatibilityFromError(
410
+ modelId: string,
411
+ errorInfo: { code?: string },
412
+ ): void {
413
+ if (errorInfo.code === 'model_not_supported') {
414
+ saveCopilotCompatibility(modelId, {
415
+ modelSupported: false,
416
+ chatCompletionsSupported: false,
417
+ })
418
+ } else if (errorInfo.code === 'unsupported_api_for_model') {
419
+ saveCopilotCompatibility(modelId, {
420
+ chatCompletionsSupported: false,
421
+ })
422
+ }
423
+ }
424
+
425
+ async function sendCopilotChatCompletion(params: {
426
+ oauthToken: string
427
+ modelId: string
428
+ messages: OpenAIMessage[]
429
+ isStreaming: boolean
430
+ maxTokens?: number
431
+ tools?: OpenAITool[]
432
+ signal?: AbortSignal
433
+ }): Promise<Response> {
434
+ const compatibility = getCachedCopilotCompatibility(params.modelId)
435
+ if (compatibility?.modelSupported === false) {
436
+ return createCachedCopilotErrorResponse(
437
+ `Copilot model "${params.modelId}" was previously rejected as unsupported.`,
438
+ 'model_not_supported',
439
+ )
440
+ }
441
+ if (compatibility?.chatCompletionsSupported === false) {
442
+ return createCachedCopilotErrorResponse(
443
+ `Copilot model "${params.modelId}" does not support the /chat/completions endpoint.`,
444
+ 'unsupported_api_for_model',
445
+ )
446
+ }
447
+
448
+ let outputTokenParam = getPreferredCopilotOutputTokenParam(params.modelId)
449
+ let requestBody = buildCopilotChatRequestBody({
450
+ modelId: params.modelId,
451
+ messages: params.messages,
452
+ isStreaming: params.isStreaming,
453
+ maxTokens: params.maxTokens,
454
+ tools: params.tools,
455
+ outputTokenParam,
456
+ })
457
+
458
+ let response = await postCopilotChatCompletion({
459
+ oauthToken: params.oauthToken,
460
+ requestBody,
461
+ signal: params.signal,
462
+ })
463
+ if (response.ok) {
464
+ saveCopilotCompatibility(params.modelId, {
465
+ modelSupported: true,
466
+ chatCompletionsSupported: true,
467
+ outputTokenParam,
468
+ })
469
+ return response
470
+ }
471
+
472
+ const errorInfo = await createCopilotErrorInfo(response)
473
+ if (!params.maxTokens) {
474
+ saveCopilotCompatibilityFromError(params.modelId, errorInfo)
475
+ return response
476
+ }
477
+ const suggestedParam = getSuggestedCopilotOutputTokenParam(
478
+ outputTokenParam,
479
+ errorInfo.message,
480
+ )
481
+ if (!suggestedParam) {
482
+ saveCopilotCompatibilityFromError(params.modelId, errorInfo)
483
+ return response
484
+ }
485
+
486
+ savePreferredCopilotOutputTokenParam(params.modelId, suggestedParam)
487
+ outputTokenParam = suggestedParam
488
+ requestBody = buildCopilotChatRequestBody({
489
+ modelId: params.modelId,
490
+ messages: params.messages,
491
+ isStreaming: params.isStreaming,
492
+ maxTokens: params.maxTokens,
493
+ tools: params.tools,
494
+ outputTokenParam,
495
+ })
496
+
497
+ response = await postCopilotChatCompletion({
498
+ oauthToken: params.oauthToken,
499
+ requestBody,
500
+ signal: params.signal,
501
+ })
502
+ if (response.ok) {
503
+ saveCopilotCompatibility(params.modelId, {
504
+ modelSupported: true,
505
+ chatCompletionsSupported: true,
506
+ outputTokenParam,
507
+ })
508
+ return response
509
+ }
510
+
511
+ saveCopilotCompatibilityFromError(
512
+ params.modelId,
513
+ await createCopilotErrorInfo(response),
514
+ )
515
+ return response
516
+ }
517
+
518
+ type OpenAIMessage = {
519
+ role: 'system' | 'user' | 'assistant' | 'tool'
520
+ content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>
521
+ tool_calls?: Array<{
522
+ id: string
523
+ type: 'function'
524
+ function: { name: string; arguments: string }
525
+ }>
526
+ tool_call_id?: string
527
+ }
528
+
529
+ type OpenAITool = {
530
+ type: 'function'
531
+ function: {
532
+ name: string
533
+ description: string
534
+ parameters: Record<string, unknown>
535
+ }
536
+ }
537
+
538
+ export type AnthropicContentBlock =
539
+ | { type: 'text'; text: string }
540
+ | { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
541
+ | { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
542
+ | { type: 'tool_result'; tool_use_id: string; content: string | Array<{ type: string; text?: string }> }
543
+ | { type: 'thinking'; thinking: string }
544
+ | Record<string, unknown>
545
+
546
+ export type AnthropicMessage = {
547
+ role: 'user' | 'assistant'
548
+ content: string | AnthropicContentBlock[]
549
+ }
550
+
551
+ export function convertAnthropicMessagesToOpenAI(
552
+ messages: AnthropicMessage[],
553
+ systemPrompt?: string,
554
+ ): OpenAIMessage[] {
555
+ const result: OpenAIMessage[] = []
556
+
557
+ if (systemPrompt) {
558
+ result.push({ role: 'system', content: systemPrompt })
559
+ }
560
+
561
+ for (const msg of messages) {
562
+ if (typeof msg.content === 'string') {
563
+ result.push({ role: msg.role, content: msg.content })
564
+ continue
565
+ }
566
+
567
+ if (msg.role === 'user') {
568
+ const parts: Array<{ type: string; text?: string; image_url?: { url: string } }> = []
569
+ const toolResults: OpenAIMessage[] = []
570
+
571
+ for (const block of msg.content) {
572
+ if (block.type === 'text') {
573
+ parts.push({ type: 'text', text: (block as { type: 'text'; text: string }).text })
574
+ } else if (block.type === 'image') {
575
+ const imgBlock = block as { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
576
+ parts.push({
577
+ type: 'image_url',
578
+ image_url: { url: `data:${imgBlock.source.media_type};base64,${imgBlock.source.data}` },
579
+ })
580
+ } else if (block.type === 'tool_result') {
581
+ const trBlock = block as { type: 'tool_result'; tool_use_id: string; content: string | Array<{ type: string; text?: string }> }
582
+ let content = ''
583
+ if (typeof trBlock.content === 'string') {
584
+ content = trBlock.content
585
+ } else if (Array.isArray(trBlock.content)) {
586
+ content = trBlock.content
587
+ .filter(c => c.type === 'text')
588
+ .map(c => c.text || '')
589
+ .join('\n')
590
+ }
591
+ toolResults.push({
592
+ role: 'tool',
593
+ content,
594
+ tool_call_id: trBlock.tool_use_id,
595
+ })
596
+ }
597
+ }
598
+
599
+ if (toolResults.length > 0) {
600
+ result.push(...toolResults)
601
+ if (parts.length > 0) {
602
+ result.push({ role: 'user', content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text! : parts })
603
+ }
604
+ } else if (parts.length > 0) {
605
+ result.push({ role: 'user', content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text! : parts })
606
+ }
607
+ } else if (msg.role === 'assistant') {
608
+ const textParts: string[] = []
609
+ const toolCalls: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }> = []
610
+
611
+ for (const block of msg.content) {
612
+ if (block.type === 'text') {
613
+ textParts.push((block as { type: 'text'; text: string }).text)
614
+ } else if (block.type === 'tool_use') {
615
+ const tuBlock = block as { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
616
+ toolCalls.push({
617
+ id: tuBlock.id,
618
+ type: 'function',
619
+ function: {
620
+ name: tuBlock.name,
621
+ arguments: JSON.stringify(tuBlock.input),
622
+ },
623
+ })
624
+ }
625
+ }
626
+
627
+ const assistantMsg: OpenAIMessage = {
628
+ role: 'assistant',
629
+ content: textParts.join('\n') || '',
630
+ }
631
+ if (toolCalls.length > 0) {
632
+ assistantMsg.tool_calls = toolCalls
633
+ }
634
+ result.push(assistantMsg)
635
+ }
636
+ }
637
+
638
+ return result
639
+ }
640
+
641
+ export function convertAnthropicToolsToOpenAI(
642
+ tools: Array<{ name: string; description?: string; input_schema?: Record<string, unknown> }>,
643
+ ): OpenAITool[] {
644
+ return tools.map(tool => ({
645
+ type: 'function' as const,
646
+ function: {
647
+ name: tool.name,
648
+ description: tool.description || '',
649
+ parameters: tool.input_schema || { type: 'object', properties: {} },
650
+ },
651
+ }))
652
+ }
653
+
654
+ export type CopilotStreamEvent =
655
+ | { type: 'message_start'; message: { id: string; type: 'message'; role: 'assistant'; model: string; content: []; usage: { input_tokens: number; output_tokens: number } } }
656
+ | { type: 'content_block_start'; index: number; content_block: { type: 'text'; text: string } }
657
+ | { type: 'content_block_delta'; index: number; delta: { type: 'text_delta'; text: string } }
658
+ | { type: 'content_block_stop'; index: number }
659
+ | { type: 'message_delta'; delta: { stop_reason: string }; usage: { output_tokens: number } }
660
+ | { type: 'message_stop' }
661
+
662
+ export async function* streamCopilotRequest(
663
+ model: string,
664
+ messages: AnthropicMessage[],
665
+ systemPrompt: string | undefined,
666
+ tools: Array<{ name: string; description?: string; input_schema?: Record<string, unknown> }>,
667
+ signal: AbortSignal,
668
+ ): AsyncGenerator<CopilotStreamEvent> {
669
+ const provider = getCopilotProvider()
670
+ if (!provider?.oauthToken) {
671
+ throw new Error('GitHub Copilot is not connected. Use /connect to authenticate.')
672
+ }
673
+
674
+ const copilotModelId = getCopilotModelId(model)
675
+ const openaiMessages = convertAnthropicMessagesToOpenAI(messages, systemPrompt)
676
+ const openaiTools = tools.length > 0 ? convertAnthropicToolsToOpenAI(tools) : undefined
677
+ const response = await sendCopilotChatCompletion({
678
+ oauthToken: provider.oauthToken,
679
+ modelId: copilotModelId,
680
+ messages: openaiMessages,
681
+ isStreaming: true,
682
+ maxTokens: 16384,
683
+ tools: openaiTools,
684
+ signal,
685
+ })
686
+
687
+ if (!response.ok) {
688
+ const errorText = await response.text().catch(() => 'unknown error')
689
+ throw new Error(`Copilot API error (${response.status}): ${errorText}`)
690
+ }
691
+
692
+ if (!response.body) {
693
+ throw new Error('No response body from Copilot API')
694
+ }
695
+
696
+ const messageId = `msg_copilot_${Date.now()}`
697
+ let contentIndex = 0
698
+ let hasStartedContent = false
699
+ let currentToolCallIndex = -1
700
+ const toolCalls: Map<number, { id: string; name: string; arguments: string }> = new Map()
701
+ let totalOutputTokens = 0
702
+
703
+ yield {
704
+ type: 'message_start',
705
+ message: {
706
+ id: messageId,
707
+ type: 'message',
708
+ role: 'assistant',
709
+ model: copilotModelId,
710
+ content: [],
711
+ usage: { input_tokens: 0, output_tokens: 0 },
712
+ },
713
+ }
714
+
715
+ const reader = response.body.getReader()
716
+ const decoder = new TextDecoder()
717
+ let buffer = ''
718
+
719
+ try {
720
+ while (true) {
721
+ const { done, value } = await reader.read()
722
+ if (done) break
723
+
724
+ buffer += decoder.decode(value, { stream: true })
725
+
726
+ const lines = buffer.split('\n')
727
+ buffer = lines.pop() || ''
728
+
729
+ for (const line of lines) {
730
+ if (!line.startsWith('data: ')) continue
731
+ const data = line.slice(6).trim()
732
+ if (data === '[DONE]') {
733
+ if (hasStartedContent) {
734
+ yield { type: 'content_block_stop', index: contentIndex - 1 }
735
+ }
736
+ for (const [idx, tc] of toolCalls) {
737
+ yield {
738
+ type: 'content_block_stop',
739
+ index: contentIndex + idx,
740
+ }
741
+ }
742
+ yield {
743
+ type: 'message_delta',
744
+ delta: { stop_reason: toolCalls.size > 0 ? 'tool_use' : 'end_turn' },
745
+ usage: { output_tokens: totalOutputTokens },
746
+ }
747
+ yield { type: 'message_stop' }
748
+ return
749
+ }
750
+
751
+ let chunk: {
752
+ choices?: Array<{
753
+ delta?: {
754
+ content?: string | null
755
+ tool_calls?: Array<{
756
+ index: number
757
+ id?: string
758
+ function?: { name?: string; arguments?: string }
759
+ }>
760
+ role?: string
761
+ }
762
+ finish_reason?: string | null
763
+ }>
764
+ usage?: { completion_tokens?: number; prompt_tokens?: number; total_tokens?: number }
765
+ }
766
+
767
+ try {
768
+ chunk = JSON.parse(data)
769
+ } catch {
770
+ continue
771
+ }
772
+
773
+ if (chunk.usage?.completion_tokens) {
774
+ totalOutputTokens = chunk.usage.completion_tokens
775
+ }
776
+
777
+ const choice = chunk.choices?.[0]
778
+ if (!choice?.delta) continue
779
+
780
+ const delta = choice.delta
781
+
782
+ if (delta.content != null && delta.content !== '') {
783
+ if (!hasStartedContent) {
784
+ hasStartedContent = true
785
+ yield {
786
+ type: 'content_block_start',
787
+ index: contentIndex,
788
+ content_block: { type: 'text', text: '' },
789
+ }
790
+ }
791
+ yield {
792
+ type: 'content_block_delta',
793
+ index: contentIndex,
794
+ delta: { type: 'text_delta', text: delta.content },
795
+ }
796
+ }
797
+
798
+ if (delta.tool_calls) {
799
+ for (const tc of delta.tool_calls) {
800
+ if (tc.id) {
801
+ if (hasStartedContent && currentToolCallIndex === -1) {
802
+ yield { type: 'content_block_stop', index: contentIndex }
803
+ contentIndex++
804
+ hasStartedContent = false
805
+ }
806
+ currentToolCallIndex = tc.index
807
+ toolCalls.set(tc.index, {
808
+ id: tc.id,
809
+ name: tc.function?.name || '',
810
+ arguments: tc.function?.arguments || '',
811
+ })
812
+ const toolBlockIndex = hasStartedContent ? contentIndex + 1 + tc.index : contentIndex + tc.index
813
+ yield {
814
+ type: 'content_block_start' as const,
815
+ index: toolBlockIndex,
816
+ content_block: {
817
+ type: 'text',
818
+ text: '',
819
+ } as any,
820
+ }
821
+ } else if (tc.function?.arguments) {
822
+ const existing = toolCalls.get(tc.index)
823
+ if (existing) {
824
+ existing.arguments += tc.function.arguments
825
+ }
826
+ }
827
+ }
828
+ }
829
+
830
+ if (choice.finish_reason) {
831
+ if (hasStartedContent) {
832
+ yield { type: 'content_block_stop', index: contentIndex }
833
+ }
834
+ yield {
835
+ type: 'message_delta',
836
+ delta: { stop_reason: choice.finish_reason === 'tool_calls' ? 'tool_use' : 'end_turn' },
837
+ usage: { output_tokens: totalOutputTokens },
838
+ }
839
+ yield { type: 'message_stop' }
840
+ return
841
+ }
842
+ }
843
+ }
844
+ } finally {
845
+ reader.releaseLock()
846
+ }
847
+
848
+ yield {
849
+ type: 'message_delta',
850
+ delta: { stop_reason: 'end_turn' },
851
+ usage: { output_tokens: totalOutputTokens },
852
+ }
853
+ yield { type: 'message_stop' }
854
+ }
855
+
856
+ export function createCopilotFetchOverride(
857
+ model: string,
858
+ ): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
859
+ const provider = getCopilotProvider()
860
+ if (!provider?.oauthToken) {
861
+ throw new Error('GitHub Copilot is not connected')
862
+ }
863
+
864
+ const copilotModelId = getCopilotModelId(model)
865
+
866
+ return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
867
+ const url = input instanceof URL ? input.href : typeof input === 'string' ? input : input.url
868
+
869
+ if (!url.includes('/messages') && !url.includes('/v1/')) {
870
+ return fetch(input, init)
871
+ }
872
+
873
+ if (url.includes('/count_tokens') || url.includes('/models')) {
874
+ return new Response(JSON.stringify({ input_tokens: 0 }), {
875
+ status: 200,
876
+ headers: { 'Content-Type': 'application/json' },
877
+ })
878
+ }
879
+
880
+ let anthropicBody: Record<string, unknown> = {}
881
+ if (init?.body) {
882
+ try {
883
+ anthropicBody = JSON.parse(typeof init.body === 'string' ? init.body : new TextDecoder().decode(init.body as ArrayBuffer))
884
+ } catch {
885
+ return fetch(input, init)
886
+ }
887
+ }
888
+
889
+ const systemBlocks = anthropicBody.system as Array<{ type: string; text: string }> | string | undefined
890
+ let systemPrompt = ''
891
+ if (typeof systemBlocks === 'string') {
892
+ systemPrompt = systemBlocks
893
+ } else if (Array.isArray(systemBlocks)) {
894
+ systemPrompt = systemBlocks
895
+ .filter(b => b.type === 'text')
896
+ .map(b => b.text)
897
+ .join('\n\n')
898
+ }
899
+
900
+ const anthropicMessages = (anthropicBody.messages || []) as AnthropicMessage[]
901
+ const openaiMessages = convertAnthropicMessagesToOpenAI(anthropicMessages, systemPrompt)
902
+
903
+ const anthropicTools = (anthropicBody.tools || []) as Array<{ name: string; description?: string; input_schema?: Record<string, unknown> }>
904
+ const openaiTools = anthropicTools.length > 0 ? convertAnthropicToolsToOpenAI(anthropicTools) : undefined
905
+
906
+ const isStreaming = anthropicBody.stream === true
907
+ const maxTokens =
908
+ typeof anthropicBody.max_tokens === 'number'
909
+ ? anthropicBody.max_tokens
910
+ : undefined
911
+
912
+ const copilotResponse = await sendCopilotChatCompletion({
913
+ oauthToken: provider.oauthToken,
914
+ modelId: copilotModelId,
915
+ messages: openaiMessages,
916
+ isStreaming,
917
+ maxTokens,
918
+ tools: openaiTools,
919
+ signal: init?.signal,
920
+ })
921
+
922
+ if (!copilotResponse.ok) {
923
+ return copilotResponse
924
+ }
925
+
926
+ if (!isStreaming) {
927
+ const data = await copilotResponse.json() as {
928
+ id: string
929
+ choices: Array<{
930
+ message: {
931
+ role: string
932
+ content: string | null
933
+ tool_calls?: Array<{
934
+ id: string
935
+ function: { name: string; arguments: string }
936
+ }>
937
+ }
938
+ finish_reason: string
939
+ }>
940
+ usage?: { prompt_tokens: number; completion_tokens: number }
941
+ }
942
+
943
+ const choice = data.choices[0]
944
+ const anthropicContent: Array<{
945
+ type: string
946
+ text?: string
947
+ id?: string
948
+ name?: string
949
+ input?: unknown
950
+ }> = []
951
+
952
+ if (choice?.message?.content) {
953
+ anthropicContent.push({ type: 'text', text: choice.message.content })
954
+ }
955
+
956
+ if (choice?.message?.tool_calls) {
957
+ for (const tc of choice.message.tool_calls) {
958
+ anthropicContent.push({
959
+ type: 'tool_use',
960
+ id: tc.id,
961
+ name: tc.function.name,
962
+ input: JSON.parse(tc.function.arguments || '{}'),
963
+ })
964
+ }
965
+ }
966
+
967
+ const anthropicResponse = {
968
+ id: data.id || `msg_copilot_${Date.now()}`,
969
+ type: 'message',
970
+ role: 'assistant',
971
+ content: anthropicContent,
972
+ model: copilotModelId,
973
+ stop_reason: choice?.finish_reason === 'tool_calls' ? 'tool_use' : 'end_turn',
974
+ usage: {
975
+ input_tokens: data.usage?.prompt_tokens || 0,
976
+ output_tokens: data.usage?.completion_tokens || 0,
977
+ },
978
+ }
979
+
980
+ return new Response(JSON.stringify(anthropicResponse), {
981
+ status: 200,
982
+ headers: { 'Content-Type': 'application/json' },
983
+ })
984
+ }
985
+
986
+ const transformStream = convertOpenAIStreamToAnthropic(copilotResponse.body!, copilotModelId)
987
+
988
+ return new Response(transformStream, {
989
+ status: 200,
990
+ headers: {
991
+ 'Content-Type': 'text/event-stream',
992
+ 'Cache-Control': 'no-cache',
993
+ Connection: 'keep-alive',
994
+ },
995
+ })
996
+ }
997
+ }
998
+
999
+ export function convertOpenAIStreamToAnthropic(
1000
+ openaiStream: ReadableStream,
1001
+ model: string,
1002
+ ): ReadableStream<Uint8Array> {
1003
+ const encoder = new TextEncoder()
1004
+ const decoder = new TextDecoder()
1005
+
1006
+ let messageId = `msg_${Date.now()}`
1007
+ let contentIndex = 0
1008
+ let hasStartedContent = false
1009
+ let currentToolCallIndex = -1
1010
+ const toolCalls: Map<number, { id: string; name: string; arguments: string }> = new Map()
1011
+ let totalOutputTokens = 0
1012
+
1013
+ return new ReadableStream({
1014
+ async start(controller) {
1015
+ const reader = openaiStream.getReader()
1016
+ let buffer = ''
1017
+
1018
+ try {
1019
+ while (true) {
1020
+ const { done, value } = await reader.read()
1021
+ if (done) break
1022
+
1023
+ buffer += decoder.decode(value, { stream: true })
1024
+
1025
+ const lines = buffer.split('\n')
1026
+ buffer = lines.pop() || ''
1027
+
1028
+ for (const line of lines) {
1029
+ if (!line.startsWith('data: ')) continue
1030
+ const data = line.slice(6).trim()
1031
+ if (data === '[DONE]') {
1032
+ if (hasStartedContent) {
1033
+ controller.enqueue(encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex - 1}}\n\n`))
1034
+ }
1035
+ for (const [idx, tc] of toolCalls) {
1036
+ controller.enqueue(
1037
+ encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex + idx}}\n\n`),
1038
+ )
1039
+ }
1040
+ controller.enqueue(
1041
+ encoder.encode(
1042
+ `event: message_delta\ndata: {"delta":{"stop_reason":"${toolCalls.size > 0 ? 'tool_use' : 'end_turn'}"},"usage":{"output_tokens":${totalOutputTokens}}}\n\n`,
1043
+ ),
1044
+ )
1045
+ controller.enqueue(encoder.encode('event: message_stop\ndata: {}\n\n'))
1046
+ return
1047
+ }
1048
+
1049
+ let chunk: {
1050
+ choices?: Array<{
1051
+ delta?: {
1052
+ content?: string | null
1053
+ tool_calls?: Array<{
1054
+ index: number
1055
+ id?: string
1056
+ function?: { name?: string; arguments?: string }
1057
+ }>
1058
+ role?: string
1059
+ }
1060
+ finish_reason?: string | null
1061
+ }>
1062
+ usage?: { completion_tokens?: number; prompt_tokens?: number; total_tokens?: number }
1063
+ }
1064
+
1065
+ try {
1066
+ chunk = JSON.parse(data)
1067
+ } catch {
1068
+ continue
1069
+ }
1070
+
1071
+ if (chunk.usage?.completion_tokens) {
1072
+ totalOutputTokens = chunk.usage.completion_tokens
1073
+ }
1074
+
1075
+ const choice = chunk.choices?.[0]
1076
+ if (!choice?.delta) continue
1077
+
1078
+ const delta = choice.delta
1079
+
1080
+ if (delta.content != null && delta.content !== '') {
1081
+ if (!hasStartedContent) {
1082
+ hasStartedContent = true
1083
+ controller.enqueue(
1084
+ encoder.encode(
1085
+ `event: content_block_start\ndata: {"index":${contentIndex},"content_block":{"type":"text","text":""}}\n\n`,
1086
+ ),
1087
+ )
1088
+ }
1089
+ controller.enqueue(
1090
+ encoder.encode(
1091
+ `event: content_block_delta\ndata: {"index":${contentIndex},"delta":{"type":"text_delta","text":"${JSON.stringify(delta.content).slice(1, -1)}"}}\n\n`,
1092
+ ),
1093
+ )
1094
+ }
1095
+
1096
+ if (delta.tool_calls) {
1097
+ for (const tc of delta.tool_calls) {
1098
+ if (tc.id) {
1099
+ if (hasStartedContent && currentToolCallIndex === -1) {
1100
+ controller.enqueue(
1101
+ encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex}}\n\n`),
1102
+ )
1103
+ contentIndex++
1104
+ hasStartedContent = false
1105
+ }
1106
+ currentToolCallIndex = tc.index
1107
+ toolCalls.set(tc.index, {
1108
+ id: tc.id,
1109
+ name: tc.function?.name || '',
1110
+ arguments: tc.function?.arguments || '',
1111
+ })
1112
+ const toolBlockIndex =
1113
+ hasStartedContent ? contentIndex + 1 + tc.index : contentIndex + tc.index
1114
+ controller.enqueue(
1115
+ encoder.encode(
1116
+ `event: content_block_start\ndata: {"index":${toolBlockIndex},"content_block":{"type":"text","text":""}}\n\n`,
1117
+ ),
1118
+ )
1119
+ } else if (tc.function?.arguments) {
1120
+ const existing = toolCalls.get(tc.index)
1121
+ if (existing) {
1122
+ existing.arguments += tc.function.arguments
1123
+ }
1124
+ }
1125
+ }
1126
+ }
1127
+
1128
+ if (choice.finish_reason) {
1129
+ if (hasStartedContent) {
1130
+ controller.enqueue(
1131
+ encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex}}\n\n`),
1132
+ )
1133
+ }
1134
+ controller.enqueue(
1135
+ encoder.encode(
1136
+ `event: message_delta\ndata: {"delta":{"stop_reason":"${choice.finish_reason === 'tool_calls' ? 'tool_use' : 'end_turn'}"},"usage":{"output_tokens":${totalOutputTokens}}}\n\n`,
1137
+ ),
1138
+ )
1139
+ controller.enqueue(encoder.encode('event: message_stop\ndata: {}\n\n'))
1140
+ return
1141
+ }
1142
+ }
1143
+ }
1144
+ } finally {
1145
+ reader.releaseLock()
1146
+ controller.close()
1147
+ }
1148
+ },
1149
+ })
1150
+ }
src/services/api/customOpenAIClient.ts ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getGlobalConfig, type ConnectedProviderInfo } from '../../utils/config.js'
2
+ import {
3
+ convertAnthropicMessagesToOpenAI,
4
+ convertAnthropicToolsToOpenAI,
5
+ convertOpenAIStreamToAnthropic,
6
+ type AnthropicMessage,
7
+ } from './copilotClient.js'
8
+
9
+ const PREFIX = 'custom-openai:'
10
+
11
+ export function isCustomOpenAIModel(model: string | undefined): boolean {
12
+ return !!model?.startsWith(PREFIX)
13
+ }
14
+
15
+ export function getCustomOpenAIModelId(model: string): string {
16
+ const rest = model.slice(PREFIX.length).trim()
17
+ if (rest) {
18
+ return rest
19
+ }
20
+ const p = getGlobalConfig().connectedProviders?.['custom-openai']
21
+ return p?.defaultModel || 'gpt-4o-mini'
22
+ }
23
+
24
+ export function getCustomOpenAIProvider(): ConnectedProviderInfo | undefined {
25
+ return getGlobalConfig().connectedProviders?.['custom-openai']
26
+ }
27
+
28
+ export function isCustomOpenAIConnected(): boolean {
29
+ const c = getGlobalConfig()
30
+ return c.activeProvider === 'custom-openai' && !!c.connectedProviders?.['custom-openai']?.baseUrl
31
+ }
32
+
33
+ function normalizeBaseUrl(url: string): string {
34
+ return url.replace(/\/$/, '')
35
+ }
36
+
37
+ /** Supports `https://host` or `https://host/v1` style bases. */
38
+ function chatCompletionsUrl(base: string): string {
39
+ const b = normalizeBaseUrl(base)
40
+ if (b.endsWith('/v1')) {
41
+ return `${b}/chat/completions`
42
+ }
43
+ return `${b}/v1/chat/completions`
44
+ }
45
+
46
+ /**
47
+ * OpenAI-compatible `/v1/chat/completions` bridge (same protocol as GitHub Copilot path).
48
+ */
49
+ export function createCustomOpenAIFetchOverride(
50
+ model: string,
51
+ ): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
52
+ const provider = getCustomOpenAIProvider()
53
+ if (!provider?.baseUrl) {
54
+ throw new Error('Custom OpenAI-compatible API is not configured')
55
+ }
56
+
57
+ const openaiModelId = getCustomOpenAIModelId(model)
58
+ const endpoint = chatCompletionsUrl(provider.baseUrl)
59
+
60
+ return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
61
+ const url = input instanceof URL ? input.href : typeof input === 'string' ? input : input.url
62
+
63
+ if (!url.includes('/messages') && !url.includes('/v1/')) {
64
+ return fetch(input, init)
65
+ }
66
+
67
+ if (url.includes('/count_tokens') || url.includes('/models')) {
68
+ return new Response(JSON.stringify({ input_tokens: 0 }), {
69
+ status: 200,
70
+ headers: { 'Content-Type': 'application/json' },
71
+ })
72
+ }
73
+
74
+ let anthropicBody: Record<string, unknown> = {}
75
+ if (init?.body) {
76
+ try {
77
+ anthropicBody = JSON.parse(
78
+ typeof init.body === 'string' ? init.body : new TextDecoder().decode(init.body as ArrayBuffer),
79
+ )
80
+ } catch {
81
+ return fetch(input, init)
82
+ }
83
+ }
84
+
85
+ const systemBlocks = anthropicBody.system as
86
+ | Array<{ type: string; text: string }>
87
+ | string
88
+ | undefined
89
+ let systemPrompt = ''
90
+ if (typeof systemBlocks === 'string') {
91
+ systemPrompt = systemBlocks
92
+ } else if (Array.isArray(systemBlocks)) {
93
+ systemPrompt = systemBlocks
94
+ .filter(b => b.type === 'text')
95
+ .map(b => b.text)
96
+ .join('\n\n')
97
+ }
98
+
99
+ const anthropicMessages = (anthropicBody.messages || []) as AnthropicMessage[]
100
+ const openaiMessages = convertAnthropicMessagesToOpenAI(anthropicMessages, systemPrompt)
101
+
102
+ const anthropicTools = (anthropicBody.tools || []) as Array<{
103
+ name: string
104
+ description?: string
105
+ input_schema?: Record<string, unknown>
106
+ }>
107
+ const openaiTools = anthropicTools.length > 0 ? convertAnthropicToolsToOpenAI(anthropicTools) : undefined
108
+
109
+ const isStreaming = anthropicBody.stream === true
110
+
111
+ const requestBody: Record<string, unknown> = {
112
+ model: openaiModelId,
113
+ messages: openaiMessages,
114
+ stream: isStreaming,
115
+ }
116
+
117
+ if (anthropicBody.max_tokens) {
118
+ requestBody.max_tokens = anthropicBody.max_tokens
119
+ }
120
+
121
+ if (openaiTools && openaiTools.length > 0) {
122
+ requestBody.tools = openaiTools
123
+ requestBody.tool_choice = 'auto'
124
+ }
125
+
126
+ const headers: Record<string, string> = {
127
+ 'Content-Type': 'application/json',
128
+ 'User-Agent': 'claude-code/2.1.88',
129
+ }
130
+ if (provider.apiKey) {
131
+ headers.Authorization = `Bearer ${provider.apiKey}`
132
+ }
133
+
134
+ const openaiResponse = await fetch(endpoint, {
135
+ method: 'POST',
136
+ headers,
137
+ body: JSON.stringify(requestBody),
138
+ signal: init?.signal,
139
+ })
140
+
141
+ if (!openaiResponse.ok) {
142
+ return openaiResponse
143
+ }
144
+
145
+ if (!isStreaming) {
146
+ const data = (await openaiResponse.json()) as {
147
+ id: string
148
+ choices: Array<{
149
+ message: {
150
+ role: string
151
+ content: string | null
152
+ tool_calls?: Array<{
153
+ id: string
154
+ function: { name: string; arguments: string }
155
+ }>
156
+ }
157
+ finish_reason: string
158
+ }>
159
+ usage?: { prompt_tokens: number; completion_tokens: number }
160
+ }
161
+
162
+ const choice = data.choices[0]
163
+ const anthropicContent: Array<{
164
+ type: string
165
+ text?: string
166
+ id?: string
167
+ name?: string
168
+ input?: unknown
169
+ }> = []
170
+
171
+ if (choice?.message?.content) {
172
+ anthropicContent.push({ type: 'text', text: choice.message.content })
173
+ }
174
+
175
+ if (choice?.message?.tool_calls) {
176
+ for (const tc of choice.message.tool_calls) {
177
+ anthropicContent.push({
178
+ type: 'tool_use',
179
+ id: tc.id,
180
+ name: tc.function.name,
181
+ input: JSON.parse(tc.function.arguments || '{}'),
182
+ })
183
+ }
184
+ }
185
+
186
+ const anthropicResponse = {
187
+ id: data.id || `msg_custom_openai_${Date.now()}`,
188
+ type: 'message',
189
+ role: 'assistant',
190
+ content: anthropicContent,
191
+ model: openaiModelId,
192
+ stop_reason: choice?.finish_reason === 'tool_calls' ? 'tool_use' : 'end_turn',
193
+ usage: {
194
+ input_tokens: data.usage?.prompt_tokens || 0,
195
+ output_tokens: data.usage?.completion_tokens || 0,
196
+ },
197
+ }
198
+
199
+ return new Response(JSON.stringify(anthropicResponse), {
200
+ status: 200,
201
+ headers: { 'Content-Type': 'application/json' },
202
+ })
203
+ }
204
+
205
+ if (!openaiResponse.body) {
206
+ return openaiResponse
207
+ }
208
+
209
+ const transformStream = convertOpenAIStreamToAnthropic(openaiResponse.body, openaiModelId)
210
+
211
+ return new Response(transformStream, {
212
+ status: 200,
213
+ headers: {
214
+ 'Content-Type': 'text/event-stream',
215
+ 'Cache-Control': 'no-cache',
216
+ Connection: 'keep-alive',
217
+ },
218
+ })
219
+ }
220
+ }
221
+
222
+ function modelsListUrlFromOpenAIBase(base: string): string {
223
+ const b = normalizeBaseUrl(base)
224
+ if (b.endsWith('/v1')) {
225
+ return `${b}/models`
226
+ }
227
+ return `${b}/v1/models`
228
+ }
229
+
230
+ function parseOpenAIStyleModelList(json: unknown): string[] {
231
+ if (!json || typeof json !== 'object') {
232
+ return []
233
+ }
234
+ const o = json as Record<string, unknown>
235
+ const data = o.data
236
+ if (Array.isArray(data)) {
237
+ const ids: string[] = []
238
+ for (const item of data) {
239
+ if (item && typeof item === 'object' && 'id' in item && typeof (item as { id: unknown }).id === 'string') {
240
+ ids.push((item as { id: string }).id)
241
+ }
242
+ }
243
+ return [...new Set(ids.filter(Boolean))]
244
+ }
245
+ const models = o.models
246
+ if (Array.isArray(models)) {
247
+ const ids: string[] = []
248
+ for (const item of models) {
249
+ if (typeof item === 'string') {
250
+ ids.push(item)
251
+ } else if (item && typeof item === 'object' && 'id' in item && typeof (item as { id: unknown }).id === 'string') {
252
+ ids.push((item as { id: string }).id)
253
+ }
254
+ }
255
+ return [...new Set(ids.filter(Boolean))]
256
+ }
257
+ return []
258
+ }
259
+
260
+ /**
261
+ * GET /v1/models (OpenAI-compatible).
262
+ */
263
+ export async function fetchOpenAICompatibleModelIds(
264
+ baseUrl: string,
265
+ apiKey?: string,
266
+ ): Promise<string[]> {
267
+ const url = modelsListUrlFromOpenAIBase(baseUrl)
268
+ const headers: Record<string, string> = {}
269
+ if (apiKey) {
270
+ headers.Authorization = `Bearer ${apiKey}`
271
+ }
272
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(20_000) })
273
+ if (!res.ok) {
274
+ const body = await res.text().catch(() => '')
275
+ throw new Error(`OpenAI-compatible /v1/models failed (${res.status})${body ? `: ${body.slice(0, 200)}` : ''}`)
276
+ }
277
+ const json: unknown = await res.json()
278
+ const ids = parseOpenAIStyleModelList(json)
279
+ return ids
280
+ }
281
+
282
+ /**
283
+ * GET /v1/models (Anthropic API).
284
+ */
285
+ export async function fetchAnthropicCompatibleModelIds(
286
+ baseUrl: string,
287
+ apiKey?: string,
288
+ ): Promise<string[]> {
289
+ let root = baseUrl.replace(/\/$/, '')
290
+ if (root.endsWith('/v1')) {
291
+ root = root.slice(0, -3)
292
+ }
293
+ const url = `${root}/v1/models`
294
+ const headers: Record<string, string> = {
295
+ 'anthropic-version': '2023-06-01',
296
+ }
297
+ if (apiKey) {
298
+ headers['x-api-key'] = apiKey
299
+ }
300
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(20_000) })
301
+ if (!res.ok) {
302
+ const body = await res.text().catch(() => '')
303
+ throw new Error(`Anthropic /v1/models failed (${res.status})${body ? `: ${body.slice(0, 200)}` : ''}`)
304
+ }
305
+ const json: unknown = await res.json()
306
+ const ids = parseOpenAIStyleModelList(json)
307
+ return ids
308
+ }
src/services/telegram/TelegramService.ts CHANGED
@@ -1,22 +1,44 @@
1
- import { logForDebugging } from '../../utils/debug.js'
2
  import { getTelegramRuntimeConfig } from './telegramConfig.js'
3
  import type {
 
 
 
 
4
  TelegramGetMeResponse,
5
  TelegramGetUpdatesResponse,
6
  TelegramInboundEvent,
 
7
  TelegramRuntimeConfig,
8
  TelegramSendMessageResponse,
 
9
  TelegramServiceState,
10
  TelegramUpdate,
11
  } from './telegramTypes.js'
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  type Listener = () => void
14
  type InboundListener = (event: TelegramInboundEvent) => void
 
15
 
16
  const TELEGRAM_API_BASE = 'https://api.telegram.org'
17
  const MAX_TELEGRAM_MESSAGE_LENGTH = 4000
18
  const POLL_TIMEOUT_SECONDS = 25
19
  const RETRY_DELAY_MS = 3000
 
 
20
 
21
  function sleep(ms: number): Promise<void> {
22
  return new Promise(resolve => setTimeout(resolve, ms))
@@ -49,9 +71,39 @@ function hasSameConfig(
49
  )
50
  }
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  class TelegramService {
53
  private listeners = new Set<Listener>()
54
  private inboundListeners = new Set<InboundListener>()
 
55
  private state: TelegramServiceState = { status: 'stopped' }
56
  private config?: TelegramRuntimeConfig
57
  private abortController: AbortController | null = null
@@ -72,6 +124,13 @@ class TelegramService {
72
  }
73
  }
74
 
 
 
 
 
 
 
 
75
  getStateSnapshot = (): TelegramServiceState => this.state
76
 
77
  async start(config: TelegramRuntimeConfig): Promise<void> {
@@ -107,6 +166,18 @@ class TelegramService {
107
 
108
  if (runId !== this.runId || abortController.signal.aborted) return
109
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  this.setState({
111
  status: 'running',
112
  botUsername: response.result?.username,
@@ -115,7 +186,7 @@ class TelegramService {
115
  lastError: undefined,
116
  })
117
 
118
- logForDebugging(
119
  `[telegram] connected as @${response.result?.username ?? 'unknown'}`,
120
  )
121
 
@@ -155,23 +226,79 @@ class TelegramService {
155
  }
156
  }
157
 
158
- async sendMessage(chatId: string, text: string): Promise<void> {
 
 
 
 
159
  if (!this.config) {
160
  throw new Error('Telegram service is not running')
161
  }
162
 
 
 
 
 
 
 
163
  for (const chunk of chunkTelegramMessage(text)) {
164
- await this.callTelegram<TelegramSendMessageResponse>(
 
165
  this.config,
166
  'sendMessage',
167
  {
168
- chat_id: Number(chatId),
169
  text: chunk,
 
170
  },
171
  )
 
 
172
  }
 
173
  }
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  private setState(nextState: TelegramServiceState): void {
176
  this.state = nextState
177
  for (const listener of this.listeners) {
@@ -191,6 +318,12 @@ class TelegramService {
191
  listener(event)
192
  }
193
  }
 
 
 
 
 
 
194
 
195
  private async pollLoop(
196
  runId: number,
@@ -205,7 +338,7 @@ class TelegramService {
205
  {
206
  offset: this.nextUpdateOffset,
207
  timeout: POLL_TIMEOUT_SECONDS,
208
- allowed_updates: ['message'],
209
  },
210
  signal,
211
  )
@@ -216,39 +349,103 @@ class TelegramService {
216
  this.patchState({ lastError: undefined })
217
  }
218
 
219
- for (const update of response.result ?? []) {
 
 
 
 
220
  this.handleUpdate(update, config)
221
  }
222
  } catch (error) {
223
  if (signal.aborted || runId !== this.runId) return
224
 
225
  const message = normalizeTelegramError(error)
226
- logForDebugging(`[telegram] polling failed: ${message}`, {
227
- level: 'error',
228
- })
229
  this.patchState({ lastError: message })
230
  await sleep(RETRY_DELAY_MS)
231
  }
232
  }
233
  }
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  private handleUpdate(
236
  update: TelegramUpdate,
237
  config: TelegramRuntimeConfig,
238
  ): void {
239
- this.nextUpdateOffset = update.update_id + 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
- const message = update.message
242
  const chatId = message?.chat?.id
243
  const userId = message?.from?.id
244
 
245
- if (!message || chatId === undefined || userId === undefined) return
246
- if (message.from?.is_bot) return
247
- if (message.chat?.type !== 'private') return
 
 
 
 
 
 
 
 
 
 
 
248
 
249
  const normalizedChatId = String(chatId)
250
  const normalizedUserId = String(userId)
251
 
 
 
252
  if (!config.allowedUserIds.includes(normalizedUserId)) {
253
  void this.sendMessage(
254
  normalizedChatId,
@@ -264,6 +461,8 @@ class TelegramService {
264
 
265
  const text = message.text?.trim()
266
 
 
 
267
  if (!text) {
268
  void this.sendMessage(
269
  normalizedChatId,
@@ -280,6 +479,7 @@ class TelegramService {
280
  return
281
  }
282
 
 
283
  this.emitInbound({
284
  kind: 'inbound-message',
285
  chatId: normalizedChatId,
@@ -288,6 +488,9 @@ class TelegramService {
288
  messageId: message.message_id,
289
  updateId: update.update_id,
290
  })
 
 
 
291
  }
292
 
293
  private async callTelegram<T>(
@@ -296,34 +499,68 @@ class TelegramService {
296
  payload: Record<string, unknown>,
297
  signal?: AbortSignal,
298
  ): Promise<T> {
299
- const response = await fetch(
300
- `${TELEGRAM_API_BASE}/bot${config.botToken}/${method}`,
301
- {
302
- method: 'POST',
303
- headers: {
304
- 'content-type': 'application/json',
305
- },
306
- body: JSON.stringify(payload),
307
- signal,
308
- },
309
- )
310
 
311
- if (!response.ok) {
312
- throw new Error(`Telegram API ${method} failed with HTTP ${response.status}`)
313
- }
 
 
314
 
315
- const json = await response.json() as {
316
- ok?: boolean
317
- description?: string
318
- }
319
 
320
- if (!json.ok) {
321
- throw new Error(
322
- `Telegram API ${method} failed: ${json.description ?? 'unknown error'}`,
 
 
 
 
 
 
 
 
323
  )
324
- }
325
 
326
- return json as T
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  }
328
  }
329
 
 
 
1
  import { getTelegramRuntimeConfig } from './telegramConfig.js'
2
  import type {
3
+ TelegramAnswerCallbackQueryResponse,
4
+ TelegramBotCommand,
5
+ TelegramCallbackEvent,
6
+ TelegramEditMessageResponse,
7
  TelegramGetMeResponse,
8
  TelegramGetUpdatesResponse,
9
  TelegramInboundEvent,
10
+ TelegramInlineKeyboardMarkup,
11
  TelegramRuntimeConfig,
12
  TelegramSendMessageResponse,
13
+ TelegramSetMyCommandsResponse,
14
  TelegramServiceState,
15
  TelegramUpdate,
16
  } from './telegramTypes.js'
17
 
18
+ // 日志函数 - 只写入文件
19
+ function logTelegramDebug(message: string, level: 'debug' | 'error' | 'info' = 'debug'): void {
20
+ try {
21
+ const fs = require('node:fs')
22
+ const path = require('node:path')
23
+ const LOG_FILE_PATH = path.join(process.cwd(), 'log.md')
24
+ const timestamp = new Date().toISOString()
25
+ const logEntry = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`
26
+ fs.appendFileSync(LOG_FILE_PATH, logEntry, 'utf-8')
27
+ } catch (error) {
28
+ // 忽略文件写入错误
29
+ }
30
+ }
31
+
32
  type Listener = () => void
33
  type InboundListener = (event: TelegramInboundEvent) => void
34
+ type CallbackListener = (event: TelegramCallbackEvent) => void
35
 
36
  const TELEGRAM_API_BASE = 'https://api.telegram.org'
37
  const MAX_TELEGRAM_MESSAGE_LENGTH = 4000
38
  const POLL_TIMEOUT_SECONDS = 25
39
  const RETRY_DELAY_MS = 3000
40
+ const MAX_TELEGRAM_MENU_COMMANDS = 100
41
+ const TELEGRAM_COMMAND_NAME_RE = /^[a-z0-9_]{1,32}$/
42
 
43
  function sleep(ms: number): Promise<void> {
44
  return new Promise(resolve => setTimeout(resolve, ms))
 
71
  )
72
  }
73
 
74
+ function normalizeCommandDescription(description: string): string {
75
+ return description.replace(/\s+/g, ' ').trim().slice(0, 256)
76
+ }
77
+
78
+ async function getTelegramMenuCommands(): Promise<TelegramBotCommand[]> {
79
+ const { getCommands, getCommandName } = await import('../../commands.js')
80
+ const commands = await getCommands(process.cwd())
81
+ const results: TelegramBotCommand[] = []
82
+ const seen = new Set<string>()
83
+
84
+ for (const command of commands) {
85
+ const candidates = [getCommandName(command), ...(command.aliases ?? [])]
86
+ const name = candidates.find(candidate => TELEGRAM_COMMAND_NAME_RE.test(candidate))
87
+ if (!name || seen.has(name)) continue
88
+
89
+ results.push({
90
+ command: name,
91
+ description: normalizeCommandDescription(command.description),
92
+ })
93
+ seen.add(name)
94
+
95
+ if (results.length >= MAX_TELEGRAM_MENU_COMMANDS) {
96
+ break
97
+ }
98
+ }
99
+
100
+ return results
101
+ }
102
+
103
  class TelegramService {
104
  private listeners = new Set<Listener>()
105
  private inboundListeners = new Set<InboundListener>()
106
+ private callbackListeners = new Set<CallbackListener>()
107
  private state: TelegramServiceState = { status: 'stopped' }
108
  private config?: TelegramRuntimeConfig
109
  private abortController: AbortController | null = null
 
124
  }
125
  }
126
 
127
+ subscribeToCallbacks = (listener: CallbackListener): (() => void) => {
128
+ this.callbackListeners.add(listener)
129
+ return () => {
130
+ this.callbackListeners.delete(listener)
131
+ }
132
+ }
133
+
134
  getStateSnapshot = (): TelegramServiceState => this.state
135
 
136
  async start(config: TelegramRuntimeConfig): Promise<void> {
 
166
 
167
  if (runId !== this.runId || abortController.signal.aborted) return
168
 
169
+ try {
170
+ await this.refreshTelegramMenu(config, abortController.signal)
171
+ } catch (error) {
172
+ if (!abortController.signal.aborted && runId === this.runId) {
173
+ const message = normalizeTelegramError(error)
174
+ logTelegramDebug(`[telegram] failed to refresh menu: ${message}`, 'error')
175
+ this.patchState({ lastError: message })
176
+ }
177
+ }
178
+
179
+ if (runId !== this.runId || abortController.signal.aborted) return
180
+
181
  this.setState({
182
  status: 'running',
183
  botUsername: response.result?.username,
 
186
  lastError: undefined,
187
  })
188
 
189
+ logTelegramDebug(
190
  `[telegram] connected as @${response.result?.username ?? 'unknown'}`,
191
  )
192
 
 
226
  }
227
  }
228
 
229
+ async sendMessage(
230
+ chatId: string,
231
+ text: string,
232
+ replyMarkup?: TelegramInlineKeyboardMarkup,
233
+ ): Promise<number | undefined> {
234
  if (!this.config) {
235
  throw new Error('Telegram service is not running')
236
  }
237
 
238
+ const numericChatId = Number(chatId)
239
+ logTelegramDebug(`[telegram] sendMessage called: chatId=${chatId} (type: ${typeof chatId}), numericChatId=${numericChatId} (type: ${typeof numericChatId}), text length=${text.length}`)
240
+ logTelegramDebug(`[telegram] allowedUserIds: ${JSON.stringify(this.config.allowedUserIds)}`)
241
+
242
+ let lastMessageId: number | undefined
243
+
244
  for (const chunk of chunkTelegramMessage(text)) {
245
+ logTelegramDebug(`[telegram] sending chunk to chat_id: ${numericChatId}`)
246
+ const response = await this.callTelegram<TelegramSendMessageResponse>(
247
  this.config,
248
  'sendMessage',
249
  {
250
+ chat_id: numericChatId,
251
  text: chunk,
252
+ ...(replyMarkup ? { reply_markup: replyMarkup } : {}),
253
  },
254
  )
255
+ lastMessageId = response.result?.message_id
256
+ logTelegramDebug(`[telegram] sendMessage succeeded: messageId=${lastMessageId}`)
257
  }
258
+ return lastMessageId
259
  }
260
 
261
+ async editMessage(
262
+ chatId: string,
263
+ messageId: number,
264
+ text: string,
265
+ replyMarkup?: TelegramInlineKeyboardMarkup,
266
+ ): Promise<void> {
267
+ if (!this.config) {
268
+ throw new Error('Telegram service is not running')
269
+ }
270
+
271
+ await this.callTelegram<TelegramEditMessageResponse>(
272
+ this.config,
273
+ 'editMessageText',
274
+ {
275
+ chat_id: Number(chatId),
276
+ message_id: messageId,
277
+ text,
278
+ ...(replyMarkup ? { reply_markup: replyMarkup } : {}),
279
+ },
280
+ )
281
+ }
282
+
283
+ async answerCallbackQuery(
284
+ callbackQueryId: string,
285
+ text?: string,
286
+ ): Promise<void> {
287
+ if (!this.config) {
288
+ throw new Error('Telegram service is not running')
289
+ }
290
+
291
+ await this.callTelegram<TelegramAnswerCallbackQueryResponse>(
292
+ this.config,
293
+ 'answerCallbackQuery',
294
+ {
295
+ callback_query_id: callbackQueryId,
296
+ ...(text ? { text } : {}),
297
+ },
298
+ )
299
+ }
300
+
301
+
302
  private setState(nextState: TelegramServiceState): void {
303
  this.state = nextState
304
  for (const listener of this.listeners) {
 
318
  listener(event)
319
  }
320
  }
321
+
322
+ private emitCallback(event: TelegramCallbackEvent): void {
323
+ for (const listener of this.callbackListeners) {
324
+ listener(event)
325
+ }
326
+ }
327
 
328
  private async pollLoop(
329
  runId: number,
 
338
  {
339
  offset: this.nextUpdateOffset,
340
  timeout: POLL_TIMEOUT_SECONDS,
341
+ allowed_updates: ['message', 'callback_query'],
342
  },
343
  signal,
344
  )
 
349
  this.patchState({ lastError: undefined })
350
  }
351
 
352
+ const updates = response.result ?? []
353
+ logTelegramDebug(`[telegram] received ${updates.length} updates from Telegram`)
354
+
355
+ for (const update of updates) {
356
+ logTelegramDebug(`[telegram] processing update: update_id=${update.update_id}`)
357
  this.handleUpdate(update, config)
358
  }
359
  } catch (error) {
360
  if (signal.aborted || runId !== this.runId) return
361
 
362
  const message = normalizeTelegramError(error)
363
+ logTelegramDebug(`[telegram] polling failed: ${message}`, 'error')
 
 
364
  this.patchState({ lastError: message })
365
  await sleep(RETRY_DELAY_MS)
366
  }
367
  }
368
  }
369
 
370
+ private async refreshTelegramMenu(
371
+ config: TelegramRuntimeConfig,
372
+ signal: AbortSignal,
373
+ ): Promise<void> {
374
+ const commands = await getTelegramMenuCommands()
375
+
376
+ await this.callTelegram<TelegramSetMyCommandsResponse>(
377
+ config,
378
+ 'setMyCommands',
379
+ {
380
+ commands,
381
+ },
382
+ signal,
383
+ )
384
+
385
+ logTelegramDebug(`[telegram] refreshed menu commands: ${commands.length}`)
386
+ }
387
+
388
  private handleUpdate(
389
  update: TelegramUpdate,
390
  config: TelegramRuntimeConfig,
391
  ): void {
392
+ try {
393
+ logTelegramDebug(`[telegram] handleUpdate called, update_id: ${update.update_id}`)
394
+ this.nextUpdateOffset = update.update_id + 1
395
+
396
+ const message = update.message
397
+ const callbackQuery = update.callback_query
398
+
399
+ logTelegramDebug(`[telegram] update structure: has_message=${!!message}, has_callback=${!!callbackQuery}`)
400
+
401
+ if (callbackQuery?.data) {
402
+ const chatId = callbackQuery.message?.chat?.id
403
+ const userId = callbackQuery.from?.id
404
+ const messageId = callbackQuery.message?.message_id
405
+ if (
406
+ chatId !== undefined &&
407
+ userId !== undefined &&
408
+ messageId !== undefined &&
409
+ !callbackQuery.from?.is_bot
410
+ ) {
411
+ if (!config.allowedUserIds.includes(String(userId))) {
412
+ return
413
+ }
414
+ this.emitCallback({
415
+ kind: 'callback-query',
416
+ callbackQueryId: callbackQuery.id,
417
+ chatId: String(chatId),
418
+ userId: String(userId),
419
+ messageId,
420
+ data: callbackQuery.data,
421
+ })
422
+ }
423
+ return
424
+ }
425
 
 
426
  const chatId = message?.chat?.id
427
  const userId = message?.from?.id
428
 
429
+ logTelegramDebug(`[telegram] message details: chatId=${chatId}, userId=${userId}, is_bot=${message?.from?.is_bot}, chat_type=${message?.chat?.type}`)
430
+
431
+ if (!message || chatId === undefined || userId === undefined) {
432
+ logTelegramDebug(`[telegram] message rejected: missing required fields`)
433
+ return
434
+ }
435
+ if (message.from?.is_bot) {
436
+ logTelegramDebug(`[telegram] message rejected: from bot`)
437
+ return
438
+ }
439
+ if (message.chat?.type !== 'private') {
440
+ logTelegramDebug(`[telegram] message rejected: not private chat`)
441
+ return
442
+ }
443
 
444
  const normalizedChatId = String(chatId)
445
  const normalizedUserId = String(userId)
446
 
447
+ logTelegramDebug(`[telegram] user validation: normalizedUserId=${normalizedUserId}, allowedUserIds=${JSON.stringify(config.allowedUserIds)}`)
448
+
449
  if (!config.allowedUserIds.includes(normalizedUserId)) {
450
  void this.sendMessage(
451
  normalizedChatId,
 
461
 
462
  const text = message.text?.trim()
463
 
464
+ logTelegramDebug(`[telegram] inbound message from chatId: ${normalizedChatId}, userId: ${normalizedUserId}, text: ${text?.slice(0, 50)}`)
465
+
466
  if (!text) {
467
  void this.sendMessage(
468
  normalizedChatId,
 
479
  return
480
  }
481
 
482
+ logTelegramDebug(`[telegram] emitting inbound event with chatId: ${normalizedChatId}`)
483
  this.emitInbound({
484
  kind: 'inbound-message',
485
  chatId: normalizedChatId,
 
488
  messageId: message.message_id,
489
  updateId: update.update_id,
490
  })
491
+ } catch (error) {
492
+ logTelegramDebug(`[telegram] handleUpdate error: ${error instanceof Error ? error.message : String(error)}`, 'error')
493
+ }
494
  }
495
 
496
  private async callTelegram<T>(
 
499
  payload: Record<string, unknown>,
500
  signal?: AbortSignal,
501
  ): Promise<T> {
502
+ logTelegramDebug(`[telegram] calling API: ${method}`)
 
 
 
 
 
 
 
 
 
 
503
 
504
+ // 创建超时信号(30秒超时)
505
+ const timeoutController = new AbortController()
506
+ const timeoutId = setTimeout(() => {
507
+ timeoutController.abort()
508
+ }, 30000)
509
 
510
+ // 合并外部信号和超时信号
511
+ const combinedSignal = signal
512
+ ? AbortSignal.any([signal, timeoutController.signal])
513
+ : timeoutController.signal
514
 
515
+ try {
516
+ const response = await fetch(
517
+ `${TELEGRAM_API_BASE}/bot${config.botToken}/${method}`,
518
+ {
519
+ method: 'POST',
520
+ headers: {
521
+ 'content-type': 'application/json',
522
+ },
523
+ body: JSON.stringify(payload),
524
+ signal: combinedSignal,
525
+ },
526
  )
 
527
 
528
+ clearTimeout(timeoutId)
529
+
530
+ if (!response.ok) {
531
+ const errorText = await response.text().catch(() => 'unknown error')
532
+ logTelegramDebug(`[telegram] API error: ${method} failed with HTTP ${response.status}: ${errorText}`, 'error')
533
+ throw new Error(`Telegram API ${method} failed with HTTP ${response.status}: ${errorText}`)
534
+ }
535
+
536
+ const json = await response.json() as {
537
+ ok?: boolean
538
+ description?: string
539
+ }
540
+
541
+ if (!json.ok) {
542
+ logTelegramDebug(`[telegram] API error: ${method} failed: ${json.description ?? 'unknown error'}`, 'error')
543
+ throw new Error(
544
+ `Telegram API ${method} failed: ${json.description ?? 'unknown error'}`,
545
+ )
546
+ }
547
+
548
+ return json as T
549
+ } catch (error) {
550
+ clearTimeout(timeoutId)
551
+
552
+ if (error instanceof Error) {
553
+ if (error.name === 'AbortError') {
554
+ logTelegramDebug(`[telegram] API error: ${method} timed out or was aborted`, 'error')
555
+ throw new Error(`Telegram API ${method} timed out`)
556
+ }
557
+ logTelegramDebug(`[telegram] API error: ${method} failed: ${error.message}`, 'error')
558
+ throw new Error(`Telegram API ${method} failed: ${error.message}`)
559
+ }
560
+
561
+ logTelegramDebug(`[telegram] API error: ${method} failed with unknown error`, 'error')
562
+ throw new Error(`Telegram API ${method} failed with unknown error`)
563
+ }
564
  }
565
  }
566
 
src/services/telegram/interactiveCommands.ts ADDED
@@ -0,0 +1,653 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AppStateStore } from '../../state/AppState.js'
2
+ import { saveGlobalConfig } from '../../utils/config.js'
3
+ import { logForDebugging } from '../../utils/debug.js'
4
+ import { isBilledAsExtraUsage } from '../../utils/extraUsage.js'
5
+ import {
6
+ clearFastModeCooldown,
7
+ isFastModeAvailable,
8
+ isFastModeEnabled,
9
+ isFastModeSupportedByModel,
10
+ } from '../../utils/fastMode.js'
11
+ import {
12
+ getDefaultMainLoopModelSetting,
13
+ isOpus1mMergeEnabled,
14
+ renderDefaultModelSetting,
15
+ } from '../../utils/model/model.js'
16
+ import {
17
+ getModelOptions,
18
+ type ModelOption,
19
+ } from '../../utils/model/modelOptions.js'
20
+ import { fetchCopilotModels } from '../api/copilotClient.js'
21
+ import {
22
+ fetchAnthropicCompatibleModelIds,
23
+ fetchOpenAICompatibleModelIds,
24
+ } from '../api/customOpenAIClient.js'
25
+ import { telegramService } from './TelegramService.js'
26
+ import type {
27
+ TelegramCallbackEvent,
28
+ TelegramInboundEvent,
29
+ TelegramInlineKeyboardButton,
30
+ TelegramInlineKeyboardMarkup,
31
+ } from './telegramTypes.js'
32
+
33
+ const COPILOT_CLIENT_ID = 'Ov23li8tweQw6odWQebz'
34
+ const COPILOT_DEVICE_CODE_URL = 'https://github.com/login/device/code'
35
+ const COPILOT_ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token'
36
+ const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
37
+
38
+ const MODEL_PREFIX = 'tg:model'
39
+ const CONNECT_PREFIX = 'tg:connect'
40
+
41
+ type ConnectPendingState =
42
+ | { kind: 'openrouter-api-key' }
43
+ | { kind: 'custom-openai-base' }
44
+ | { kind: 'custom-openai-key'; baseUrl: string }
45
+ | { kind: 'custom-anthropic-base' }
46
+ | { kind: 'custom-anthropic-key'; baseUrl: string }
47
+
48
+ type ConnectModelSelectionState = {
49
+ providerId: 'custom-openai' | 'custom-anthropic'
50
+ baseUrl: string
51
+ apiKey?: string
52
+ models: string[]
53
+ }
54
+
55
+ const pendingConnectInputs = new Map<string, ConnectPendingState>()
56
+ const pendingModelMenus = new Map<string, ModelOption[]>()
57
+ const pendingConnectModelMenus = new Map<string, ConnectModelSelectionState>()
58
+
59
+ function chunk<T>(items: T[], size: number): T[][] {
60
+ const rows: T[][] = []
61
+ for (let i = 0; i < items.length; i += size) {
62
+ rows.push(items.slice(i, i + size))
63
+ }
64
+ return rows
65
+ }
66
+
67
+ function createKeyboard(
68
+ rows: TelegramInlineKeyboardButton[][],
69
+ ): TelegramInlineKeyboardMarkup {
70
+ return { inline_keyboard: rows }
71
+ }
72
+
73
+ function buildModelKeyboard(options: ModelOption[]): TelegramInlineKeyboardMarkup {
74
+ return createKeyboard(
75
+ chunk(
76
+ options.map((option, index) => ({
77
+ text: option.label,
78
+ callback_data: `${MODEL_PREFIX}:select:${index}`,
79
+ })),
80
+ 2,
81
+ ),
82
+ )
83
+ }
84
+
85
+ function buildConnectKeyboard(): TelegramInlineKeyboardMarkup {
86
+ return createKeyboard([
87
+ [
88
+ { text: 'GitHub Copilot', callback_data: `${CONNECT_PREFIX}:provider:github-copilot` },
89
+ { text: 'OpenRouter', callback_data: `${CONNECT_PREFIX}:provider:openrouter` },
90
+ ],
91
+ [
92
+ { text: 'Custom OpenAI', callback_data: `${CONNECT_PREFIX}:provider:custom-openai` },
93
+ { text: 'Custom Anthropic', callback_data: `${CONNECT_PREFIX}:provider:custom-anthropic` },
94
+ ],
95
+ ])
96
+ }
97
+
98
+ function buildSkipKeyKeyboard(providerId: 'custom-openai' | 'custom-anthropic') {
99
+ return createKeyboard([
100
+ [
101
+ {
102
+ text: '跳过 API Key',
103
+ callback_data: `${CONNECT_PREFIX}:skipkey:${providerId}`,
104
+ },
105
+ ],
106
+ ])
107
+ }
108
+
109
+ function buildModelSelectionKeyboard(
110
+ providerId: 'custom-openai' | 'custom-anthropic',
111
+ models: string[],
112
+ ): TelegramInlineKeyboardMarkup {
113
+ return createKeyboard(
114
+ chunk(
115
+ models.map((model, index) => ({
116
+ text: model,
117
+ callback_data: `${CONNECT_PREFIX}:model:${providerId}:${index}`,
118
+ })),
119
+ 1,
120
+ ),
121
+ )
122
+ }
123
+
124
+ function renderModelSelectionText(store: AppStateStore, options: ModelOption[]): string {
125
+ const state = store.getState()
126
+ const current = state.mainLoopModel
127
+ const currentLabel = renderDefaultModelSetting(
128
+ current ?? getDefaultMainLoopModelSetting(),
129
+ )
130
+ const lines = [
131
+ `当前模型: ${currentLabel}${current === null ? ' (default)' : ''}`,
132
+ '',
133
+ '请选择要切换的模型:',
134
+ ]
135
+
136
+ options.forEach((option, index) => {
137
+ lines.push(`${index + 1}. ${option.label} - ${option.description}`)
138
+ })
139
+
140
+ return lines.join('\n')
141
+ }
142
+
143
+ function applyModelSelection(
144
+ store: AppStateStore,
145
+ model: ModelOption['value'],
146
+ ): string {
147
+ const state = store.getState()
148
+ let message = `已切换到模型 ${renderDefaultModelSetting(model ?? getDefaultMainLoopModelSetting())}${model === null ? ' (default)' : ''}`
149
+ let wasFastModeToggledOn: boolean | undefined
150
+
151
+ store.setState(prev => ({
152
+ ...prev,
153
+ mainLoopModel: model,
154
+ mainLoopModelForSession: null,
155
+ }))
156
+
157
+ if (isFastModeEnabled()) {
158
+ clearFastModeCooldown()
159
+ if (!isFastModeSupportedByModel(model) && state.fastMode) {
160
+ store.setState(prev => ({
161
+ ...prev,
162
+ fastMode: false,
163
+ }))
164
+ wasFastModeToggledOn = false
165
+ } else if (
166
+ isFastModeSupportedByModel(model) &&
167
+ isFastModeAvailable() &&
168
+ state.fastMode
169
+ ) {
170
+ message += ' · Fast mode ON'
171
+ wasFastModeToggledOn = true
172
+ }
173
+ }
174
+
175
+ if (isBilledAsExtraUsage(model, wasFastModeToggledOn === true, isOpus1mMergeEnabled())) {
176
+ message += ' · Billed as extra usage'
177
+ }
178
+
179
+ if (wasFastModeToggledOn === false) {
180
+ message += ' · Fast mode OFF'
181
+ }
182
+
183
+ return message
184
+ }
185
+
186
+ async function beginCopilotOAuth(chatId: string): Promise<void> {
187
+ const response = await fetch(COPILOT_DEVICE_CODE_URL, {
188
+ method: 'POST',
189
+ headers: {
190
+ Accept: 'application/json',
191
+ 'Content-Type': 'application/json',
192
+ },
193
+ body: JSON.stringify({
194
+ client_id: COPILOT_CLIENT_ID,
195
+ scope: 'read:user',
196
+ }),
197
+ })
198
+
199
+ if (!response.ok) {
200
+ throw new Error('无法发起 GitHub Copilot 设备授权')
201
+ }
202
+
203
+ const data = (await response.json()) as {
204
+ verification_uri: string
205
+ user_code: string
206
+ device_code: string
207
+ interval: number
208
+ }
209
+
210
+ await telegramService.sendMessage(
211
+ chatId,
212
+ [
213
+ 'GitHub Copilot 连接已开始。',
214
+ `1. 打开: ${data.verification_uri}`,
215
+ `2. 输入代码: ${data.user_code}`,
216
+ '授权完成后,我会自动继续。',
217
+ ].join('\n'),
218
+ )
219
+
220
+ void pollCopilotOAuth(chatId, data)
221
+ }
222
+
223
+ async function pollCopilotOAuth(
224
+ chatId: string,
225
+ data: {
226
+ device_code: string
227
+ interval: number
228
+ },
229
+ ): Promise<void> {
230
+ let currentInterval = data.interval
231
+
232
+ while (true) {
233
+ await new Promise(resolve =>
234
+ setTimeout(resolve, currentInterval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS),
235
+ )
236
+
237
+ const response = await fetch(COPILOT_ACCESS_TOKEN_URL, {
238
+ method: 'POST',
239
+ headers: {
240
+ Accept: 'application/json',
241
+ 'Content-Type': 'application/json',
242
+ },
243
+ body: JSON.stringify({
244
+ client_id: COPILOT_CLIENT_ID,
245
+ device_code: data.device_code,
246
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
247
+ }),
248
+ })
249
+
250
+ if (!response.ok) {
251
+ throw new Error('获取 GitHub Copilot access token 失败')
252
+ }
253
+
254
+ const result = (await response.json()) as {
255
+ access_token?: string
256
+ error?: string
257
+ interval?: number
258
+ }
259
+
260
+ if (result.access_token) {
261
+ saveGlobalConfig(current => ({
262
+ ...current,
263
+ connectedProviders: {
264
+ ...(current.connectedProviders || {}),
265
+ 'github-copilot': {
266
+ oauthToken: result.access_token,
267
+ connectedAt: new Date().toISOString(),
268
+ },
269
+ },
270
+ activeProvider: current.activeProvider || 'github-copilot',
271
+ }))
272
+
273
+ fetchCopilotModels()
274
+ .then(models => {
275
+ saveGlobalConfig(current => ({
276
+ ...current,
277
+ copilotModelsCache: { models, fetchedAt: Date.now() },
278
+ }))
279
+ })
280
+ .catch(() => {})
281
+
282
+ await telegramService.sendMessage(
283
+ chatId,
284
+ 'GitHub Copilot 已连接成功。之后可用 /model 选择 Copilot 模型。',
285
+ )
286
+ return
287
+ }
288
+
289
+ if (result.error === 'authorization_pending') {
290
+ continue
291
+ }
292
+
293
+ if (result.error === 'slow_down') {
294
+ currentInterval = result.interval ?? currentInterval + 5
295
+ continue
296
+ }
297
+
298
+ if (result.error === 'expired_token') {
299
+ throw new Error('GitHub Copilot 授权已过期,请重新执行 /connect')
300
+ }
301
+
302
+ if (result.error === 'access_denied') {
303
+ throw new Error('GitHub Copilot 授权被拒绝')
304
+ }
305
+
306
+ if (result.error) {
307
+ throw new Error(`GitHub Copilot OAuth 错误: ${result.error}`)
308
+ }
309
+ }
310
+ }
311
+
312
+ async function handleConnectTextInput(
313
+ chatId: string,
314
+ text: string,
315
+ ): Promise<boolean> {
316
+ const pending = pendingConnectInputs.get(chatId)
317
+ if (!pending) return false
318
+
319
+ pendingConnectInputs.delete(chatId)
320
+
321
+ switch (pending.kind) {
322
+ case 'openrouter-api-key': {
323
+ if (!text.trim()) {
324
+ await telegramService.sendMessage(chatId, 'API Key 不能为空,请重新执行 /connect。')
325
+ return true
326
+ }
327
+ saveGlobalConfig(current => ({
328
+ ...current,
329
+ connectedProviders: {
330
+ ...(current.connectedProviders || {}),
331
+ openrouter: {
332
+ apiKey: text.trim(),
333
+ connectedAt: new Date().toISOString(),
334
+ },
335
+ },
336
+ activeProvider: current.activeProvider || 'openrouter',
337
+ }))
338
+ await telegramService.sendMessage(chatId, 'OpenRouter 已连接成功。')
339
+ return true
340
+ }
341
+ case 'custom-openai-base': {
342
+ const baseUrl = text.trim()
343
+ if (!baseUrl) {
344
+ await telegramService.sendMessage(chatId, 'Base URL 不能为空,请重新执行 /connect。')
345
+ return true
346
+ }
347
+ pendingConnectInputs.set(chatId, {
348
+ kind: 'custom-openai-key',
349
+ baseUrl,
350
+ })
351
+ await telegramService.sendMessage(
352
+ chatId,
353
+ '请输入 Custom OpenAI 的 API Key,或者点击下方按钮跳过。',
354
+ buildSkipKeyKeyboard('custom-openai'),
355
+ )
356
+ return true
357
+ }
358
+ case 'custom-anthropic-base': {
359
+ const baseUrl = text.trim()
360
+ if (!baseUrl) {
361
+ await telegramService.sendMessage(chatId, 'Base URL 不能为空,请重新执行 /connect。')
362
+ return true
363
+ }
364
+ pendingConnectInputs.set(chatId, {
365
+ kind: 'custom-anthropic-key',
366
+ baseUrl,
367
+ })
368
+ await telegramService.sendMessage(
369
+ chatId,
370
+ '请输入 Custom Anthropic 的 API Key,或者点击下方按钮跳过。',
371
+ buildSkipKeyKeyboard('custom-anthropic'),
372
+ )
373
+ return true
374
+ }
375
+ case 'custom-openai-key': {
376
+ await startCustomProviderModelFetch(
377
+ chatId,
378
+ 'custom-openai',
379
+ pending.baseUrl,
380
+ text.trim() || undefined,
381
+ )
382
+ return true
383
+ }
384
+ case 'custom-anthropic-key': {
385
+ await startCustomProviderModelFetch(
386
+ chatId,
387
+ 'custom-anthropic',
388
+ pending.baseUrl,
389
+ text.trim() || undefined,
390
+ )
391
+ return true
392
+ }
393
+ }
394
+ }
395
+
396
+ async function startCustomProviderModelFetch(
397
+ chatId: string,
398
+ providerId: 'custom-openai' | 'custom-anthropic',
399
+ baseUrl: string,
400
+ apiKey?: string,
401
+ ): Promise<void> {
402
+ const models =
403
+ providerId === 'custom-openai'
404
+ ? await fetchOpenAICompatibleModelIds(baseUrl, apiKey)
405
+ : await fetchAnthropicCompatibleModelIds(baseUrl, apiKey)
406
+
407
+ if (models.length === 0) {
408
+ await telegramService.sendMessage(
409
+ chatId,
410
+ '没有从 /v1/models 获取到可用模型,请检查 Base URL 或 API Key。',
411
+ )
412
+ return
413
+ }
414
+
415
+ pendingConnectModelMenus.set(chatId, {
416
+ providerId,
417
+ baseUrl,
418
+ apiKey,
419
+ models,
420
+ })
421
+
422
+ await telegramService.sendMessage(
423
+ chatId,
424
+ `请选择 ${providerId === 'custom-openai' ? 'Custom OpenAI' : 'Custom Anthropic'} 的默认模型:`,
425
+ buildModelSelectionKeyboard(providerId, models),
426
+ )
427
+ }
428
+
429
+ export async function maybeHandleTelegramInteractiveInput(
430
+ event: TelegramInboundEvent,
431
+ store: AppStateStore,
432
+ ): Promise<boolean> {
433
+ const text = event.text.trim()
434
+
435
+ if (await handleConnectTextInput(event.chatId, text)) {
436
+ return true
437
+ }
438
+
439
+ if (text === '/model') {
440
+ const options = getModelOptions(store.getState().fastMode).slice(0, 20)
441
+ pendingModelMenus.set(event.chatId, options)
442
+ await telegramService.sendMessage(
443
+ event.chatId,
444
+ renderModelSelectionText(store, options),
445
+ buildModelKeyboard(options),
446
+ )
447
+ return true
448
+ }
449
+
450
+ if (text === '/connect') {
451
+ await telegramService.sendMessage(
452
+ event.chatId,
453
+ '请选择要连接的 provider:',
454
+ buildConnectKeyboard(),
455
+ )
456
+ return true
457
+ }
458
+
459
+ return false
460
+ }
461
+
462
+ export async function handleTelegramCallback(
463
+ event: TelegramCallbackEvent,
464
+ store: AppStateStore,
465
+ ): Promise<boolean> {
466
+ if (event.data.startsWith(`${MODEL_PREFIX}:select:`)) {
467
+ const index = Number(event.data.split(':').at(-1))
468
+ const options = pendingModelMenus.get(event.chatId)
469
+ const option = Number.isFinite(index) ? options?.[index] : undefined
470
+
471
+ await telegramService.answerCallbackQuery(
472
+ event.callbackQueryId,
473
+ option ? `已选择 ${option.label}` : '模型选项已失效',
474
+ )
475
+
476
+ if (!option) {
477
+ return true
478
+ }
479
+
480
+ const message = applyModelSelection(store, option.value)
481
+ await telegramService.editMessage(event.chatId, event.messageId, message)
482
+ pendingModelMenus.delete(event.chatId)
483
+ return true
484
+ }
485
+
486
+ if (event.data.startsWith(`${CONNECT_PREFIX}:provider:`)) {
487
+ const providerId = event.data.split(':').at(-1)
488
+ await telegramService.answerCallbackQuery(event.callbackQueryId)
489
+
490
+ switch (providerId) {
491
+ case 'github-copilot':
492
+ await telegramService.editMessage(
493
+ event.chatId,
494
+ event.messageId,
495
+ 'GitHub Copilot 正在发起设备授权,请稍候...',
496
+ )
497
+ await beginCopilotOAuth(event.chatId)
498
+ return true
499
+ case 'openrouter':
500
+ pendingConnectInputs.set(event.chatId, { kind: 'openrouter-api-key' })
501
+ await telegramService.editMessage(
502
+ event.chatId,
503
+ event.messageId,
504
+ '请直接发送 OpenRouter API Key。',
505
+ )
506
+ return true
507
+ case 'custom-openai':
508
+ pendingConnectInputs.set(event.chatId, { kind: 'custom-openai-base' })
509
+ await telegramService.editMessage(
510
+ event.chatId,
511
+ event.messageId,
512
+ '请直接发送 Custom OpenAI 的 Base URL。',
513
+ )
514
+ return true
515
+ case 'custom-anthropic':
516
+ pendingConnectInputs.set(event.chatId, { kind: 'custom-anthropic-base' })
517
+ await telegramService.editMessage(
518
+ event.chatId,
519
+ event.messageId,
520
+ '请直接发送 Custom Anthropic 的 Base URL。',
521
+ )
522
+ return true
523
+ default:
524
+ await telegramService.editMessage(
525
+ event.chatId,
526
+ event.messageId,
527
+ '暂不支持这个 provider。',
528
+ )
529
+ return true
530
+ }
531
+ }
532
+
533
+ if (event.data.startsWith(`${CONNECT_PREFIX}:skipkey:`)) {
534
+ const providerId = event.data.split(':').at(-1)
535
+ const pending = pendingConnectInputs.get(event.chatId)
536
+ await telegramService.answerCallbackQuery(event.callbackQueryId)
537
+
538
+ if (
539
+ providerId === 'custom-openai' &&
540
+ pending?.kind === 'custom-openai-key'
541
+ ) {
542
+ pendingConnectInputs.delete(event.chatId)
543
+ await telegramService.editMessage(
544
+ event.chatId,
545
+ event.messageId,
546
+ '正在获取 Custom OpenAI 模型列表...',
547
+ )
548
+ await startCustomProviderModelFetch(
549
+ event.chatId,
550
+ 'custom-openai',
551
+ pending.baseUrl,
552
+ )
553
+ return true
554
+ }
555
+
556
+ if (
557
+ providerId === 'custom-anthropic' &&
558
+ pending?.kind === 'custom-anthropic-key'
559
+ ) {
560
+ pendingConnectInputs.delete(event.chatId)
561
+ await telegramService.editMessage(
562
+ event.chatId,
563
+ event.messageId,
564
+ '正在获取 Custom Anthropic 模型列表...',
565
+ )
566
+ await startCustomProviderModelFetch(
567
+ event.chatId,
568
+ 'custom-anthropic',
569
+ pending.baseUrl,
570
+ )
571
+ return true
572
+ }
573
+
574
+ return true
575
+ }
576
+
577
+ if (event.data.startsWith(`${CONNECT_PREFIX}:model:`)) {
578
+ const [, , , providerId, indexRaw] = event.data.split(':')
579
+ const selection = pendingConnectModelMenus.get(event.chatId)
580
+ const index = Number(indexRaw)
581
+ const model = Number.isFinite(index) ? selection?.models[index] : undefined
582
+
583
+ await telegramService.answerCallbackQuery(
584
+ event.callbackQueryId,
585
+ model ? `已选择 ${model}` : '模型选项已失效',
586
+ )
587
+
588
+ if (
589
+ !selection ||
590
+ !model ||
591
+ (providerId !== 'custom-openai' && providerId !== 'custom-anthropic')
592
+ ) {
593
+ return true
594
+ }
595
+
596
+ if (providerId !== selection.providerId) {
597
+ return true
598
+ }
599
+
600
+ saveGlobalConfig(current => ({
601
+ ...current,
602
+ connectedProviders: {
603
+ ...(current.connectedProviders || {}),
604
+ [providerId]: {
605
+ baseUrl: selection.baseUrl,
606
+ defaultModel: model,
607
+ ...(selection.apiKey ? { apiKey: selection.apiKey } : {}),
608
+ connectedAt: new Date().toISOString(),
609
+ },
610
+ },
611
+ activeProvider: providerId,
612
+ ...(providerId === 'custom-openai'
613
+ ? {
614
+ openaiCustomModelsCache: selection.models.map(id => ({ id })),
615
+ }
616
+ : {
617
+ anthropicCustomModelsCache: selection.models.map(id => ({ id })),
618
+ }),
619
+ }))
620
+
621
+ pendingConnectModelMenus.delete(event.chatId)
622
+ await telegramService.editMessage(
623
+ event.chatId,
624
+ event.messageId,
625
+ `${providerId === 'custom-openai' ? 'Custom OpenAI' : 'Custom Anthropic'} 已连接成功,默认模型为 ${model}。`,
626
+ )
627
+ return true
628
+ }
629
+
630
+ return false
631
+ }
632
+
633
+ export function clearTelegramInteractiveState(chatId?: string): void {
634
+ if (chatId) {
635
+ pendingConnectInputs.delete(chatId)
636
+ pendingModelMenus.delete(chatId)
637
+ pendingConnectModelMenus.delete(chatId)
638
+ return
639
+ }
640
+
641
+ pendingConnectInputs.clear()
642
+ pendingModelMenus.clear()
643
+ pendingConnectModelMenus.clear()
644
+ }
645
+
646
+ export function logTelegramInteractiveError(error: unknown): void {
647
+ logForDebugging(
648
+ `[telegram] interactive handler failed: ${
649
+ error instanceof Error ? error.message : String(error)
650
+ }`,
651
+ { level: 'error' },
652
+ )
653
+ }
src/services/telegram/telegramConfig.ts CHANGED
@@ -12,6 +12,14 @@ export function getTelegramConfig(): TelegramConfig | undefined {
12
  return getGlobalConfig().telegram
13
  }
14
 
 
 
 
 
 
 
 
 
15
  export function saveTelegramConfig(updates: TelegramConfigDraft): void {
16
  saveGlobalConfig(current => ({
17
  ...current,
 
12
  return getGlobalConfig().telegram
13
  }
14
 
15
+ export function hasTelegramRuntimeConfig(): boolean {
16
+ const telegram = getTelegramConfig()
17
+ return Boolean(
18
+ telegram?.botToken?.trim() &&
19
+ (telegram.allowedUserIds?.filter(Boolean).length ?? 0) > 0,
20
+ )
21
+ }
22
+
23
  export function saveTelegramConfig(updates: TelegramConfigDraft): void {
24
  saveGlobalConfig(current => ({
25
  ...current,
src/services/telegram/telegramTypes.ts CHANGED
@@ -42,6 +42,22 @@ export type TelegramUpdate = {
42
  is_bot?: boolean
43
  }
44
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  }
46
 
47
  export type TelegramGetMeResponse = {
@@ -64,6 +80,47 @@ export type TelegramGetUpdatesResponse = {
64
  export type TelegramSendMessageResponse = {
65
  ok: boolean
66
  description?: string
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  }
68
 
69
  export type TelegramConfigDraft = Partial<TelegramConfig>
 
42
  is_bot?: boolean
43
  }
44
  }
45
+ callback_query?: {
46
+ id: string
47
+ data?: string
48
+ from?: {
49
+ id?: number
50
+ is_bot?: boolean
51
+ }
52
+ message?: {
53
+ message_id: number
54
+ chat?: {
55
+ id?: number
56
+ type?: string
57
+ }
58
+ text?: string
59
+ }
60
+ }
61
  }
62
 
63
  export type TelegramGetMeResponse = {
 
80
  export type TelegramSendMessageResponse = {
81
  ok: boolean
82
  description?: string
83
+ result?: {
84
+ message_id: number
85
+ }
86
+ }
87
+
88
+ export type TelegramSetMyCommandsResponse = {
89
+ ok: boolean
90
+ description?: string
91
+ }
92
+
93
+ export type TelegramEditMessageResponse = {
94
+ ok: boolean
95
+ description?: string
96
+ }
97
+
98
+ export type TelegramAnswerCallbackQueryResponse = {
99
+ ok: boolean
100
+ description?: string
101
+ }
102
+
103
+ export type TelegramBotCommand = {
104
+ command: string
105
+ description: string
106
+ }
107
+
108
+ export type TelegramInlineKeyboardButton = {
109
+ text: string
110
+ callback_data: string
111
+ }
112
+
113
+ export type TelegramInlineKeyboardMarkup = {
114
+ inline_keyboard: TelegramInlineKeyboardButton[][]
115
+ }
116
+
117
+ export type TelegramCallbackEvent = {
118
+ kind: 'callback-query'
119
+ callbackQueryId: string
120
+ chatId: string
121
+ userId: string
122
+ messageId: number
123
+ data: string
124
  }
125
 
126
  export type TelegramConfigDraft = Partial<TelegramConfig>