chenbhao commited on
Commit
799831d
·
1 Parent(s): fae9128

fix: fetch active opencode model

Browse files
src/components/OpenCodeLoginFlow.tsx CHANGED
@@ -12,14 +12,11 @@ type OpenCodeLoginFlowProps = {
12
 
13
  type LoginMode = 'menu' | 'free_models' | 'api_key'
14
 
15
- const FREE_MODELS = [
16
- { label: 'Big Pickle (推荐)', value: 'big-pickle', description: '旗舰模型,限时免费,适合复杂任务' },
17
- { label: 'GPT 5 Nano', value: 'gpt-5-nano', description: '永久免费,轻量快速,隐私安全' },
18
- { label: 'MiniMax M2.5 Free', value: 'minimax-m2.5-free', description: '限时免费,编码推理强' },
19
- { label: 'GLM 4.7 Free', value: 'glm-4.7-free', description: '限时免费,智谱开源模型' },
20
- { label: 'Kimi K2.5 Free', value: 'kimi-k2.5-free', description: '限时免费,月之暗面模型' },
21
- { label: 'Nemotron 3 Super Free', value: 'nemotron-3-super-free', description: '限时免费,NVIDIA 模型,100万上下文' },
22
- ]
23
 
24
  export function OpenCodeLoginFlow({
25
  onDone,
@@ -27,12 +24,14 @@ export function OpenCodeLoginFlow({
27
  }: OpenCodeLoginFlowProps): React.ReactNode {
28
  const [mode, setMode] = useState<LoginMode>('menu')
29
  const [isBusy, setIsBusy] = useState(false)
 
30
  const [status, setStatus] = useState<string | null>(null)
31
  const [inputValue, setInputValue] = useState('')
32
  const [cursorOffset, setCursorOffset] = useState(0)
33
- const [selectedModel, setSelectedModel] = useState('big-pickle')
34
  const [existingApiKey, setExistingApiKey] = useState<string | null>(null)
35
  const [existingModelName, setExistingModelName] = useState<string | null>(null)
 
36
 
37
  useEffect(() => {
38
  const key = getOpenCodeApiKey()
@@ -46,6 +45,52 @@ export function OpenCodeLoginFlow({
46
  }
47
  }, [])
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  const menuOptions = [
50
  {
51
  label: (
@@ -81,16 +126,23 @@ export function OpenCodeLoginFlow({
81
 
82
  if (value === 'free_models') {
83
  setMode('free_models')
 
 
 
 
84
  return
85
  }
86
  }
87
 
88
- async function handleFreeModelSelect(): Promise<void> {
 
 
 
89
  setIsBusy(true)
90
  setStatus(null)
91
  try {
92
  // 免费模型不需要 API Key,直接保存模型名称
93
- await saveOpenCodeApiKey('', selectedModel)
94
  onDone()
95
  } catch (error) {
96
  setStatus(error instanceof Error ? error.message : String(error))
@@ -131,19 +183,39 @@ export function OpenCodeLoginFlow({
131
  }
132
 
133
  if (mode === 'free_models') {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  return (
135
  <Box flexDirection="column" gap={1}>
136
  <Text bold={true}>
137
  {startingMessage ?? 'Select a free model from OpenCode Zen.'}
138
  </Text>
139
  <Text dimColor={true}>
140
- Free models are limited-time offers except GPT 5 Nano (permanently free).
141
- {'\n'}
142
- No API key needed — just start coding!
143
  </Text>
144
  <Text>Select model:</Text>
145
  <Select
146
- options={FREE_MODELS.map(model => ({
147
  label: (
148
  <Text>
149
  {model.label}{' '}
@@ -155,8 +227,8 @@ export function OpenCodeLoginFlow({
155
  }))}
156
  onChange={value => {
157
  setSelectedModel(value)
158
- // 选择后直接提交
159
- void handleFreeModelSelect()
160
  }}
161
  />
162
  <Box flexDirection="row" gap={2} marginTop={1}>
@@ -222,12 +294,12 @@ export function OpenCodeLoginFlow({
222
  <Box flexDirection="column" gap={1}>
223
  <Text bold={true}>
224
  {startingMessage ??
225
- 'Better-Clawd can use OpenCode Zen free models or with a standard OpenCode Zen API key.'}
226
  </Text>
227
  <Text dimColor={true}>
228
  OpenCode Zen provides free models out of the box — no API key required.
229
  {'\n'}
230
- Free models include Big Pickle, GPT 5 Nano, and more.
231
  </Text>
232
  {status ? <Text color="error">{status}</Text> : null}
233
  <Box>
 
12
 
13
  type LoginMode = 'menu' | 'free_models' | 'api_key'
14
 
15
+ type OpencodeModel = {
16
+ label: string
17
+ value: string
18
+ description: string
19
+ }
 
 
 
20
 
21
  export function OpenCodeLoginFlow({
22
  onDone,
 
24
  }: OpenCodeLoginFlowProps): React.ReactNode {
25
  const [mode, setMode] = useState<LoginMode>('menu')
26
  const [isBusy, setIsBusy] = useState(false)
27
+ const [isLoadingModels, setIsLoadingModels] = useState(false)
28
  const [status, setStatus] = useState<string | null>(null)
29
  const [inputValue, setInputValue] = useState('')
30
  const [cursorOffset, setCursorOffset] = useState(0)
31
+ const [selectedModel, setSelectedModel] = useState('')
32
  const [existingApiKey, setExistingApiKey] = useState<string | null>(null)
33
  const [existingModelName, setExistingModelName] = useState<string | null>(null)
34
+ const [availableModels, setAvailableModels] = useState<OpencodeModel[]>([])
35
 
36
  useEffect(() => {
37
  const key = getOpenCodeApiKey()
 
45
  }
46
  }, [])
47
 
48
+ const fetchModels = async () => {
49
+ setIsLoadingModels(true)
50
+ try {
51
+ // Use native https module for better reliability in bundled environment
52
+ const https = await import('https')
53
+ const data = await new Promise<string>((resolve, reject) => {
54
+ const req = https.get('https://opencode.ai/zen/v1/models', {
55
+ headers: {
56
+ 'User-Agent': 'claude-code/2.1.88',
57
+ },
58
+ timeout: 15000,
59
+ }, res => {
60
+ let body = ''
61
+ res.on('data', chunk => { body += chunk })
62
+ res.on('end', () => resolve(body))
63
+ res.on('error', reject)
64
+ })
65
+ req.on('error', reject)
66
+ req.on('timeout', () => {
67
+ req.destroy()
68
+ reject(new Error('Request timed out'))
69
+ })
70
+ })
71
+
72
+ const parsed = JSON.parse(data) as { data?: Array<{ id: string }> }
73
+ if (Array.isArray(parsed.data) && parsed.data.length > 0) {
74
+ const models = parsed.data.map(m => ({
75
+ label: m.id,
76
+ value: m.id,
77
+ description: m.id.endsWith('-free') ? '限时免费模型' : 'OpenCode Zen 模型',
78
+ }))
79
+ setAvailableModels(models)
80
+ if (!selectedModel && models.length > 0) {
81
+ setSelectedModel(models[0].value)
82
+ }
83
+ } else {
84
+ setStatus('API returned empty model list')
85
+ }
86
+ } catch (err) {
87
+ console.error('OpenCode models fetch error:', err)
88
+ setStatus(`Failed to fetch models: ${err instanceof Error ? err.message : 'Unknown error'}`)
89
+ } finally {
90
+ setIsLoadingModels(false)
91
+ }
92
+ }
93
+
94
  const menuOptions = [
95
  {
96
  label: (
 
126
 
127
  if (value === 'free_models') {
128
  setMode('free_models')
129
+ // Fetch models when entering free models mode
130
+ if (availableModels.length === 0) {
131
+ await fetchModels()
132
+ }
133
  return
134
  }
135
  }
136
 
137
+ async function handleFreeModelSelect(modelValue?: string): Promise<void> {
138
+ const modelToSave = modelValue || selectedModel
139
+ if (!modelToSave) return
140
+
141
  setIsBusy(true)
142
  setStatus(null)
143
  try {
144
  // 免费模型不需要 API Key,直接保存模型名称
145
+ await saveOpenCodeApiKey('', modelToSave)
146
  onDone()
147
  } catch (error) {
148
  setStatus(error instanceof Error ? error.message : String(error))
 
183
  }
184
 
185
  if (mode === 'free_models') {
186
+ if (isLoadingModels) {
187
+ return (
188
+ <Box flexDirection="column" gap={1}>
189
+ <Text>Loading available models...</Text>
190
+ </Box>
191
+ )
192
+ }
193
+
194
+ if (availableModels.length === 0) {
195
+ return (
196
+ <Box flexDirection="column" gap={1}>
197
+ <Text bold={true}>No models available</Text>
198
+ <Text dimColor={true}>
199
+ {status || 'Failed to fetch models from OpenCode Zen API.'}
200
+ </Text>
201
+ <Box flexDirection="row" gap={2} marginTop={1}>
202
+ <Text color="subtle">Esc to go back</Text>
203
+ </Box>
204
+ </Box>
205
+ )
206
+ }
207
+
208
  return (
209
  <Box flexDirection="column" gap={1}>
210
  <Text bold={true}>
211
  {startingMessage ?? 'Select a free model from OpenCode Zen.'}
212
  </Text>
213
  <Text dimColor={true}>
214
+ Free models are available from OpenCode Zen API.
 
 
215
  </Text>
216
  <Text>Select model:</Text>
217
  <Select
218
+ options={availableModels.map(model => ({
219
  label: (
220
  <Text>
221
  {model.label}{' '}
 
227
  }))}
228
  onChange={value => {
229
  setSelectedModel(value)
230
+ // 选择后自动提交,直接传递 value 避免闭包问题
231
+ void handleFreeModelSelect(value)
232
  }}
233
  />
234
  <Box flexDirection="row" gap={2} marginTop={1}>
 
294
  <Box flexDirection="column" gap={1}>
295
  <Text bold={true}>
296
  {startingMessage ??
297
+ 'Better-Clawd can use OpenCode Zen free models or with a Zen API key.'}
298
  </Text>
299
  <Text dimColor={true}>
300
  OpenCode Zen provides free models out of the box — no API key required.
301
  {'\n'}
302
+ Free models are fetched from the API dynamically.
303
  </Text>
304
  {status ? <Text color="error">{status}</Text> : null}
305
  <Box>
src/components/SearchableModelPicker.tsx CHANGED
@@ -65,28 +65,25 @@ export function SearchableModelPicker({
65
  }
66
  }, [isFastMode, authVersion])
67
 
68
- // Poll for OpenRouter models when cache is empty
69
  React.useEffect(() => {
70
  let mounted = true
71
  let pollTimer: NodeJS.Timeout | null = null
72
 
73
- async function checkOpenRouterCache() {
74
  try {
75
  const { getAPIProvider } = await import('../utils/model/providers.js')
76
  const provider = getAPIProvider()
77
 
78
  if (provider === 'openrouter' && modelOptions.length === 0) {
79
- // Check cache every 500ms
80
  pollTimer = setInterval(async () => {
81
  if (!mounted) {
82
  if (pollTimer) clearInterval(pollTimer)
83
  return
84
  }
85
-
86
  try {
87
  const { hasOpenRouterModelsCache } = await import('../utils/model/openRouterModels.js')
88
  if (hasOpenRouterModelsCache()) {
89
- // Cache is now populated, trigger re-render
90
  if (mounted) {
91
  setAppState(prev => ({ ...prev, authVersion: prev.authVersion + 1 }))
92
  }
@@ -97,8 +94,34 @@ export function SearchableModelPicker({
97
  if (pollTimer) clearInterval(pollTimer)
98
  }
99
  }, 500)
 
 
 
 
 
 
 
100
 
101
- // Stop polling after 10 seconds
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  setTimeout(() => {
103
  if (pollTimer && mounted) {
104
  clearInterval(pollTimer)
@@ -111,7 +134,7 @@ export function SearchableModelPicker({
111
  }
112
  }
113
 
114
- checkOpenRouterCache()
115
 
116
  return () => {
117
  mounted = false
 
65
  }
66
  }, [isFastMode, authVersion])
67
 
68
+ // Poll for OpenRouter/OpenCode models when cache is empty
69
  React.useEffect(() => {
70
  let mounted = true
71
  let pollTimer: NodeJS.Timeout | null = null
72
 
73
+ async function checkProviderCache() {
74
  try {
75
  const { getAPIProvider } = await import('../utils/model/providers.js')
76
  const provider = getAPIProvider()
77
 
78
  if (provider === 'openrouter' && modelOptions.length === 0) {
 
79
  pollTimer = setInterval(async () => {
80
  if (!mounted) {
81
  if (pollTimer) clearInterval(pollTimer)
82
  return
83
  }
 
84
  try {
85
  const { hasOpenRouterModelsCache } = await import('../utils/model/openRouterModels.js')
86
  if (hasOpenRouterModelsCache()) {
 
87
  if (mounted) {
88
  setAppState(prev => ({ ...prev, authVersion: prev.authVersion + 1 }))
89
  }
 
94
  if (pollTimer) clearInterval(pollTimer)
95
  }
96
  }, 500)
97
+ setTimeout(() => {
98
+ if (pollTimer && mounted) {
99
+ clearInterval(pollTimer)
100
+ pollTimer = null
101
+ }
102
+ }, 10000)
103
+ }
104
 
105
+ if (provider === 'opencode' && modelOptions.length <= 1) {
106
+ pollTimer = setInterval(async () => {
107
+ if (!mounted) {
108
+ if (pollTimer) clearInterval(pollTimer)
109
+ return
110
+ }
111
+ try {
112
+ const { getCachedOpencodeModels } = await import('../services/api/opencodeClient.js')
113
+ const models = getCachedOpencodeModels()
114
+ if (models && models.length > 0) {
115
+ if (mounted) {
116
+ setAppState(prev => ({ ...prev, authVersion: prev.authVersion + 1 }))
117
+ }
118
+ if (pollTimer) clearInterval(pollTimer)
119
+ }
120
+ } catch (error) {
121
+ console.error('Error checking OpenCode cache:', error)
122
+ if (pollTimer) clearInterval(pollTimer)
123
+ }
124
+ }, 500)
125
  setTimeout(() => {
126
  if (pollTimer && mounted) {
127
  clearInterval(pollTimer)
 
134
  }
135
  }
136
 
137
+ checkProviderCache()
138
 
139
  return () => {
140
  mounted = false
src/services/api/client.ts CHANGED
@@ -149,6 +149,8 @@ export async function getAnthropicClient({
149
  const provider = getAPIProvider()
150
  let opencodeFetchOverride: ClientOptions['fetch'] | undefined
151
  if (provider === 'opencode' && model) {
 
 
152
  opencodeFetchOverride = createOpenCodeFetchOverride(model)
153
  }
154
 
 
149
  const provider = getAPIProvider()
150
  let opencodeFetchOverride: ClientOptions['fetch'] | undefined
151
  if (provider === 'opencode' && model) {
152
+ // Fetch models in background
153
+ import('./opencodeClient.js').then(m => m.fetchOpencodeModels())
154
  opencodeFetchOverride = createOpenCodeFetchOverride(model)
155
  }
156
 
src/services/api/opencodeClient.ts CHANGED
@@ -1,13 +1,173 @@
1
  import { getOpenCodeApiKey, getOpenCodeModelName } from '../../utils/auth.js'
2
  import {
3
- convertAnthropicMessagesToOpenAI,
4
  convertAnthropicToolsToOpenAI,
5
  convertOpenAIStreamToAnthropic,
6
  type AnthropicMessage,
 
7
  } from './copilotClient.js'
8
 
9
  const OPENCODE_BASE_URL = 'https://opencode.ai/zen/v1'
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  function normalizeBaseUrl(url: string): string {
12
  return url.replace(/\/$/, '')
13
  }
@@ -119,6 +279,7 @@ export function createOpenCodeFetchOverride(
119
  message: {
120
  role: string
121
  content: string | null
 
122
  tool_calls?: Array<{
123
  id: string
124
  function: { name: string; arguments: string }
@@ -138,6 +299,10 @@ export function createOpenCodeFetchOverride(
138
  input?: unknown
139
  }> = []
140
 
 
 
 
 
141
  if (choice?.message?.content) {
142
  anthropicContent.push({ type: 'text', text: choice.message.content })
143
  }
@@ -176,7 +341,7 @@ export function createOpenCodeFetchOverride(
176
  return openaiResponse
177
  }
178
 
179
- const transformStream = convertOpenAIStreamToAnthropic(openaiResponse.body, modelName)
180
 
181
  return new Response(transformStream, {
182
  status: 200,
@@ -188,3 +353,180 @@ export function createOpenCodeFetchOverride(
188
  })
189
  }
190
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { getOpenCodeApiKey, getOpenCodeModelName } from '../../utils/auth.js'
2
  import {
 
3
  convertAnthropicToolsToOpenAI,
4
  convertOpenAIStreamToAnthropic,
5
  type AnthropicMessage,
6
+ type AnthropicContentBlock,
7
  } from './copilotClient.js'
8
 
9
  const OPENCODE_BASE_URL = 'https://opencode.ai/zen/v1'
10
 
11
+ let cachedModels: Array<{ id: string; name?: string }> | null = null
12
+ let fetchPromise: Promise<void> | null = null
13
+
14
+ export async function fetchOpencodeModels(): Promise<void> {
15
+ if (cachedModels || fetchPromise) return
16
+
17
+ fetchPromise = (async () => {
18
+ try {
19
+ const https = await import('https')
20
+ const data = await new Promise<string>((resolve, reject) => {
21
+ const apiKey = getOpenCodeApiKey()
22
+ const headers: Record<string, string> = {
23
+ 'User-Agent': 'claude-code/2.1.88',
24
+ }
25
+ if (apiKey) {
26
+ headers.Authorization = `Bearer ${apiKey}`
27
+ }
28
+ const req = https.get(`${OPENCODE_BASE_URL}/models`, {
29
+ headers,
30
+ timeout: 15000,
31
+ }, res => {
32
+ let body = ''
33
+ res.on('data', chunk => { body += chunk })
34
+ res.on('end', () => resolve(body))
35
+ res.on('error', reject)
36
+ })
37
+ req.on('error', reject)
38
+ req.on('timeout', () => {
39
+ req.destroy()
40
+ reject(new Error('Request timed out'))
41
+ })
42
+ })
43
+
44
+ const parsed = JSON.parse(data) as { data?: Array<{ id: string; name?: string }> }
45
+ if (Array.isArray(parsed.data)) {
46
+ cachedModels = parsed.data.map(m => ({ id: m.id, name: m.name || m.id }))
47
+ }
48
+ } catch {
49
+ // Ignore errors
50
+ } finally {
51
+ fetchPromise = null
52
+ }
53
+ })()
54
+
55
+ await fetchPromise
56
+ }
57
+
58
+ export function getCachedOpencodeModels(): Array<{ id: string; name?: string }> {
59
+ return cachedModels || []
60
+ }
61
+
62
+ type OpenAIMessage = {
63
+ role: 'system' | 'user' | 'assistant' | 'tool'
64
+ content: string | Array<{ type: string; text?: string; image_url?: { url: string } }> | null
65
+ tool_calls?: Array<{
66
+ id: string
67
+ type: 'function'
68
+ function: { name: string; arguments: string }
69
+ }>
70
+ tool_call_id?: string
71
+ reasoning_content?: string
72
+ }
73
+
74
+ function convertAnthropicMessagesToOpenAI(
75
+ messages: AnthropicMessage[],
76
+ systemPrompt?: string,
77
+ ): OpenAIMessage[] {
78
+ const result: OpenAIMessage[] = []
79
+
80
+ if (systemPrompt) {
81
+ result.push({ role: 'system', content: systemPrompt })
82
+ }
83
+
84
+ for (const msg of messages) {
85
+ if (typeof msg.content === 'string') {
86
+ result.push({ role: msg.role, content: msg.content })
87
+ continue
88
+ }
89
+
90
+ if (msg.role === 'user') {
91
+ const parts: Array<{ type: string; text?: string; image_url?: { url: string } }> = []
92
+ const toolResults: OpenAIMessage[] = []
93
+
94
+ for (const block of msg.content) {
95
+ if (block.type === 'text') {
96
+ parts.push({ type: 'text', text: (block as { type: 'text'; text: string }).text })
97
+ } else if (block.type === 'image') {
98
+ const imgBlock = block as { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
99
+ parts.push({
100
+ type: 'image_url',
101
+ image_url: { url: `data:${imgBlock.source.media_type};base64,${imgBlock.source.data}` },
102
+ })
103
+ } else if (block.type === 'tool_result') {
104
+ const trBlock = block as { type: 'tool_result'; tool_use_id: string; content: string | Array<{ type: string; text?: string }> }
105
+ let content = ''
106
+ if (typeof trBlock.content === 'string') {
107
+ content = trBlock.content
108
+ } else if (Array.isArray(trBlock.content)) {
109
+ content = trBlock.content
110
+ .filter(c => c.type === 'text')
111
+ .map(c => c.text || '')
112
+ .join('\n')
113
+ }
114
+ toolResults.push({
115
+ role: 'tool',
116
+ content,
117
+ tool_call_id: trBlock.tool_use_id,
118
+ })
119
+ }
120
+ }
121
+
122
+ if (toolResults.length > 0) {
123
+ result.push(...toolResults)
124
+ if (parts.length > 0) {
125
+ result.push({ role: 'user', content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text! : parts })
126
+ }
127
+ } else if (parts.length > 0) {
128
+ result.push({ role: 'user', content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text! : parts })
129
+ }
130
+ } else if (msg.role === 'assistant') {
131
+ const textParts: string[] = []
132
+ const toolCalls: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }> = []
133
+ let reasoningContent: string | undefined
134
+
135
+ for (const block of msg.content) {
136
+ if (block.type === 'text') {
137
+ textParts.push((block as { type: 'text'; text: string }).text)
138
+ } else if (block.type === 'tool_use') {
139
+ const tuBlock = block as { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
140
+ toolCalls.push({
141
+ id: tuBlock.id,
142
+ type: 'function',
143
+ function: {
144
+ name: tuBlock.name,
145
+ arguments: JSON.stringify(tuBlock.input),
146
+ },
147
+ })
148
+ } else if (block.type === 'thinking') {
149
+ const thinkingBlock = block as { type: 'thinking'; thinking: string }
150
+ reasoningContent = thinkingBlock.thinking
151
+ }
152
+ }
153
+
154
+ const assistantMsg: OpenAIMessage = {
155
+ role: 'assistant',
156
+ content: textParts.join('\n') || null,
157
+ }
158
+ if (reasoningContent) {
159
+ assistantMsg.reasoning_content = reasoningContent
160
+ }
161
+ if (toolCalls.length > 0) {
162
+ assistantMsg.tool_calls = toolCalls
163
+ }
164
+ result.push(assistantMsg)
165
+ }
166
+ }
167
+
168
+ return result
169
+ }
170
+
171
  function normalizeBaseUrl(url: string): string {
172
  return url.replace(/\/$/, '')
173
  }
 
279
  message: {
280
  role: string
281
  content: string | null
282
+ reasoning_content?: string
283
  tool_calls?: Array<{
284
  id: string
285
  function: { name: string; arguments: string }
 
299
  input?: unknown
300
  }> = []
301
 
302
+ if (choice?.message?.reasoning_content) {
303
+ anthropicContent.push({ type: 'thinking', thinking: choice.message.reasoning_content })
304
+ }
305
+
306
  if (choice?.message?.content) {
307
  anthropicContent.push({ type: 'text', text: choice.message.content })
308
  }
 
341
  return openaiResponse
342
  }
343
 
344
+ const transformStream = convertOpenAIStreamToAnthropicWithReasoning(openaiResponse.body, modelName)
345
 
346
  return new Response(transformStream, {
347
  status: 200,
 
353
  })
354
  }
355
  }
356
+
357
+ function convertOpenAIStreamToAnthropicWithReasoning(
358
+ openaiStream: ReadableStream,
359
+ model: string,
360
+ ): ReadableStream<Uint8Array> {
361
+ const encoder = new TextEncoder()
362
+ const decoder = new TextDecoder()
363
+
364
+ let messageId = `msg_${Date.now()}`
365
+ let contentIndex = 0
366
+ let hasStartedContent = false
367
+ let hasReasoningBlock = false
368
+ let currentToolCallIndex = -1
369
+ const toolCalls: Map<number, { id: string; name: string; arguments: string }> = new Map()
370
+ let totalOutputTokens = 0
371
+
372
+ return new ReadableStream({
373
+ async start(controller) {
374
+ const reader = openaiStream.getReader()
375
+ let buffer = ''
376
+
377
+ try {
378
+ while (true) {
379
+ const { done, value } = await reader.read()
380
+ if (done) break
381
+
382
+ buffer += decoder.decode(value, { stream: true })
383
+
384
+ const lines = buffer.split('\n')
385
+ buffer = lines.pop() || ''
386
+
387
+ for (const line of lines) {
388
+ if (!line.startsWith('data: ')) continue
389
+ const data = line.slice(6).trim()
390
+ if (data === '[DONE]') {
391
+ if (hasStartedContent) {
392
+ controller.enqueue(encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex - 1}}\n\n`))
393
+ }
394
+ for (const [idx, tc] of toolCalls) {
395
+ controller.enqueue(
396
+ encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex + idx}}\n\n`),
397
+ )
398
+ }
399
+ controller.enqueue(
400
+ encoder.encode(
401
+ `event: message_delta\ndata: {"delta":{"stop_reason":"${toolCalls.size > 0 ? 'tool_use' : 'end_turn'}"},"usage":{"output_tokens":${totalOutputTokens}}}\n\n`,
402
+ ),
403
+ )
404
+ controller.enqueue(encoder.encode('event: message_stop\ndata: {}\n\n'))
405
+ return
406
+ }
407
+
408
+ let chunk: {
409
+ choices?: Array<{
410
+ delta?: {
411
+ content?: string | null
412
+ reasoning_content?: string | null
413
+ tool_calls?: Array<{
414
+ index: number
415
+ id?: string
416
+ function?: { name?: string; arguments?: string }
417
+ }>
418
+ role?: string
419
+ }
420
+ finish_reason?: string | null
421
+ }>
422
+ usage?: { completion_tokens?: number; prompt_tokens?: number; total_tokens?: number }
423
+ }
424
+
425
+ try {
426
+ chunk = JSON.parse(data)
427
+ } catch {
428
+ continue
429
+ }
430
+
431
+ if (chunk.usage?.completion_tokens) {
432
+ totalOutputTokens = chunk.usage.completion_tokens
433
+ }
434
+
435
+ const choice = chunk.choices?.[0]
436
+ if (!choice?.delta) continue
437
+
438
+ const delta = choice.delta
439
+
440
+ if (delta.reasoning_content != null && delta.reasoning_content !== '') {
441
+ if (!hasReasoningBlock) {
442
+ hasReasoningBlock = true
443
+ controller.enqueue(
444
+ encoder.encode(
445
+ `event: content_block_start\ndata: {"index":${contentIndex},"content_block":{"type":"thinking","thinking":""}}\n\n`,
446
+ ),
447
+ )
448
+ }
449
+ controller.enqueue(
450
+ encoder.encode(
451
+ `event: content_block_delta\ndata: {"index":${contentIndex},"delta":{"type":"thinking_delta","thinking":"${JSON.stringify(delta.reasoning_content).slice(1, -1)}"}}\n\n`,
452
+ ),
453
+ )
454
+ }
455
+
456
+ if (delta.content != null && delta.content !== '') {
457
+ if (!hasStartedContent) {
458
+ hasStartedContent = true
459
+ if (hasReasoningBlock) {
460
+ controller.enqueue(
461
+ encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex}}\n\n`),
462
+ )
463
+ contentIndex++
464
+ }
465
+ controller.enqueue(
466
+ encoder.encode(
467
+ `event: content_block_start\ndata: {"index":${contentIndex},"content_block":{"type":"text","text":""}}\n\n`,
468
+ ),
469
+ )
470
+ }
471
+ controller.enqueue(
472
+ encoder.encode(
473
+ `event: content_block_delta\ndata: {"index":${contentIndex},"delta":{"type":"text_delta","text":"${JSON.stringify(delta.content).slice(1, -1)}"}}\n\n`,
474
+ ),
475
+ )
476
+ }
477
+
478
+ if (delta.tool_calls) {
479
+ for (const tc of delta.tool_calls) {
480
+ if (tc.id) {
481
+ if (hasStartedContent && currentToolCallIndex === -1) {
482
+ controller.enqueue(
483
+ encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex}}\n\n`),
484
+ )
485
+ contentIndex++
486
+ hasStartedContent = false
487
+ }
488
+ currentToolCallIndex = tc.index
489
+ toolCalls.set(tc.index, {
490
+ id: tc.id,
491
+ name: tc.function?.name || '',
492
+ arguments: tc.function?.arguments || '',
493
+ })
494
+ const toolBlockIndex =
495
+ hasStartedContent ? contentIndex + 1 + tc.index : contentIndex + tc.index
496
+ controller.enqueue(
497
+ encoder.encode(
498
+ `event: content_block_start\ndata: {"index":${toolBlockIndex},"content_block":{"type":"text","text":""}}\n\n`,
499
+ ),
500
+ )
501
+ } else if (tc.function?.arguments) {
502
+ const existing = toolCalls.get(tc.index)
503
+ if (existing) {
504
+ existing.arguments += tc.function.arguments
505
+ }
506
+ }
507
+ }
508
+ }
509
+
510
+ if (choice.finish_reason) {
511
+ if (hasStartedContent) {
512
+ controller.enqueue(
513
+ encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex}}\n\n`),
514
+ )
515
+ }
516
+ controller.enqueue(
517
+ encoder.encode(
518
+ `event: message_delta\ndata: {"delta":{"stop_reason":"${choice.finish_reason === 'tool_calls' ? 'tool_use' : 'end_turn'}"},"usage":{"output_tokens":${totalOutputTokens}}}\n\n`,
519
+ ),
520
+ )
521
+ controller.enqueue(encoder.encode('event: message_stop\ndata: {}\n\n'))
522
+ return
523
+ }
524
+ }
525
+ }
526
+ } finally {
527
+ reader.releaseLock()
528
+ controller.close()
529
+ }
530
+ },
531
+ })
532
+ }
src/utils/auth.ts CHANGED
@@ -449,7 +449,26 @@ export function getOpenCodeApiKey(): null | string {
449
 
450
  export function getOpenCodeModelName(): null | string {
451
  const config = getGlobalConfig()
452
- return config.openCodeModelName || null
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  }
454
 
455
  export async function saveLocalModelConfig(baseUrl: string, modelName: string): Promise<void> {
 
449
 
450
  export function getOpenCodeModelName(): null | string {
451
  const config = getGlobalConfig()
452
+ if (config.authProvider === 'opencode' && config.openCodeModelName) {
453
+ return config.openCodeModelName
454
+ }
455
+ // Fallback: read config file directly in case getGlobalConfig() is stale
456
+ try {
457
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
458
+ const { readFileSync } = require('fs') as typeof import('fs')
459
+ const { getGlobalClaudeFile } = require('./env.js') as typeof import('./env.js')
460
+ const raw = readFileSync(getGlobalClaudeFile(), 'utf8')
461
+ const fileConfig = JSON.parse(raw) as {
462
+ authProvider?: string
463
+ openCodeModelName?: string
464
+ }
465
+ if (fileConfig.authProvider === 'opencode' && fileConfig.openCodeModelName) {
466
+ return fileConfig.openCodeModelName
467
+ }
468
+ } catch {
469
+ // Ignore errors
470
+ }
471
+ return null
472
  }
473
 
474
  export async function saveLocalModelConfig(baseUrl: string, modelName: string): Promise<void> {
src/utils/model/model.ts CHANGED
@@ -250,10 +250,9 @@ export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias {
250
  }
251
 
252
  // Check if using OpenCode Zen provider
 
253
  let isOpenCodeProvider = apiProvider === 'opencode'
254
-
255
- // If getAPIProvider() returns null, check the config file directly
256
- if (!isOpenCodeProvider && apiProvider === null) {
257
  try {
258
  // eslint-disable-next-line @typescript-eslint/no-require-imports
259
  const { readFileSync } = require('fs') as typeof import('fs')
 
250
  }
251
 
252
  // Check if using OpenCode Zen provider
253
+ // Always check config file directly since provider cache may be stale
254
  let isOpenCodeProvider = apiProvider === 'opencode'
255
+ if (!isOpenCodeProvider) {
 
 
256
  try {
257
  // eslint-disable-next-line @typescript-eslint/no-require-imports
258
  const { readFileSync } = require('fs') as typeof import('fs')
src/utils/model/modelOptions.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
  isClaudeAISubscriber,
5
  isMaxSubscriber,
6
  isTeamPremiumSubscriber,
 
7
  } from '../auth.js'
8
  import { getModelStrings } from './modelStrings.js'
9
  import {
@@ -489,6 +490,30 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
489
  return [getDefaultOptionForUser(fastMode)]
490
  }
491
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
492
  // OpenRouter API: Show all available models from OpenRouter
493
  if (getAPIProvider() === 'openrouter') {
494
  // Trigger background fetch of OpenRouter models (non-blocking)
 
4
  isClaudeAISubscriber,
5
  isMaxSubscriber,
6
  isTeamPremiumSubscriber,
7
+ getOpenCodeModelName,
8
  } from '../auth.js'
9
  import { getModelStrings } from './modelStrings.js'
10
  import {
 
490
  return [getDefaultOptionForUser(fastMode)]
491
  }
492
 
493
+ // OpenCode Zen: Fetch models dynamically from API
494
+ if (getAPIProvider() === 'opencode') {
495
+ const defaultOpt = getDefaultOptionForUser(fastMode)
496
+ try {
497
+ const { getCachedOpencodeModels } = require('../../services/api/opencodeClient.js')
498
+ const models = getCachedOpencodeModels()
499
+ if (models && Array.isArray(models) && models.length > 0) {
500
+ return [
501
+ defaultOpt,
502
+ ...models.map(m => ({
503
+ value: m.id,
504
+ label: m.name || m.id,
505
+ description: 'OpenCode Zen Model',
506
+ })),
507
+ ]
508
+ }
509
+ } catch {
510
+ // Ignore errors
511
+ }
512
+
513
+ // Return only default if models are not yet fetched
514
+ return [defaultOpt]
515
+ }
516
+
517
  // OpenRouter API: Show all available models from OpenRouter
518
  if (getAPIProvider() === 'openrouter') {
519
  // Trigger background fetch of OpenRouter models (non-blocking)