chenbhao commited on
Commit
124e6b2
·
1 Parent(s): 1c67f7b

fix: opencode login

Browse files
src/commands/login/login.tsx CHANGED
@@ -92,14 +92,14 @@ export async function call(
92
  const { getAPIProvider } = await import('../../utils/model/providers.js')
93
  if (getAPIProvider() === 'opencode') {
94
  const { fetchOpencodeModels } = await import('../../services/api/opencodeClient.js')
95
- void fetchOpencodeModels()
96
  }
97
 
98
  // Increment authVersion to trigger re-fetching of auth-dependent data
99
  context.setAppState(prev => ({
100
  ...prev,
101
  authVersion: prev.authVersion + 1,
102
- mainLoopModel: null,
103
  mainLoopModelForSession: null,
104
  }));
105
  }
 
92
  const { getAPIProvider } = await import('../../utils/model/providers.js')
93
  if (getAPIProvider() === 'opencode') {
94
  const { fetchOpencodeModels } = await import('../../services/api/opencodeClient.js')
95
+ await fetchOpencodeModels()
96
  }
97
 
98
  // Increment authVersion to trigger re-fetching of auth-dependent data
99
  context.setAppState(prev => ({
100
  ...prev,
101
  authVersion: prev.authVersion + 1,
102
+ mainLoopModel: 'big-pickle',
103
  mainLoopModelForSession: null,
104
  }));
105
  }
src/components/OpenCodeLoginFlow.tsx CHANGED
@@ -34,7 +34,7 @@ export function OpenCodeLoginFlow({
34
  setIsBusy(true)
35
  setStatus(null)
36
  try {
37
- await saveOpenCodeApiKey('', '')
38
  onDone()
39
  } catch (error) {
40
  setStatus(error instanceof Error ? error.message : String(error))
 
34
  setIsBusy(true)
35
  setStatus(null)
36
  try {
37
+ await saveOpenCodeApiKey('public', '')
38
  onDone()
39
  } catch (error) {
40
  setStatus(error instanceof Error ? error.message : String(error))
src/components/SearchableModelPicker.tsx CHANGED
@@ -103,8 +103,14 @@ export function SearchableModelPicker({
103
  }
104
 
105
  if (provider === 'opencode' && modelOptions.length <= 1) {
106
- const { fetchOpencodeModels } = await import('../services/api/opencodeClient.js')
107
- void fetchOpencodeModels()
 
 
 
 
 
 
108
 
109
  pollTimer = setInterval(async () => {
110
  if (!mounted) {
@@ -119,18 +125,21 @@ export function SearchableModelPicker({
119
  setAppState(prev => ({ ...prev, authVersion: prev.authVersion + 1 }))
120
  }
121
  if (pollTimer) clearInterval(pollTimer)
 
 
 
122
  }
123
  } catch (error) {
124
  console.error('Error checking OpenCode cache:', error)
125
  if (pollTimer) clearInterval(pollTimer)
126
  }
127
- }, 500)
128
  setTimeout(() => {
129
  if (pollTimer && mounted) {
130
  clearInterval(pollTimer)
131
  pollTimer = null
132
  }
133
- }, 10000)
134
  }
135
  } catch (error) {
136
  console.error('Error checking API provider:', error)
 
103
  }
104
 
105
  if (provider === 'opencode' && modelOptions.length <= 1) {
106
+ let retryCount = 0
107
+ const MAX_RETRIES = 5
108
+
109
+ async function tryFetch(): Promise<void> {
110
+ const { fetchOpencodeModels } = await import('../services/api/opencodeClient.js')
111
+ await fetchOpencodeModels()
112
+ }
113
+ void tryFetch()
114
 
115
  pollTimer = setInterval(async () => {
116
  if (!mounted) {
 
125
  setAppState(prev => ({ ...prev, authVersion: prev.authVersion + 1 }))
126
  }
127
  if (pollTimer) clearInterval(pollTimer)
128
+ } else if (retryCount < MAX_RETRIES) {
129
+ retryCount++
130
+ void tryFetch()
131
  }
132
  } catch (error) {
133
  console.error('Error checking OpenCode cache:', error)
134
  if (pollTimer) clearInterval(pollTimer)
135
  }
136
+ }, 2000)
137
  setTimeout(() => {
138
  if (pollTimer && mounted) {
139
  clearInterval(pollTimer)
140
  pollTimer = null
141
  }
142
+ }, 60000)
143
  }
144
  } catch (error) {
145
  console.error('Error checking API provider:', error)
src/services/api/client.ts CHANGED
@@ -25,7 +25,7 @@ import {
25
  getOpencodeBaseUrl,
26
  } from 'src/utils/model/providers.js'
27
  import { getProxyFetchOptions } from 'src/utils/proxy.js'
28
- import { createOpenCodeFetchOverride } from './opencodeClient.js'
29
  import {
30
  getIsNonInteractiveSession,
31
  getSessionId,
@@ -148,10 +148,11 @@ export async function getAnthropicClient({
148
  // For OpenCode provider, use custom fetch to convert Anthropic format to OpenAI format
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
 
157
  const resolvedFetch = buildFetch(fetchOverride || opencodeFetchOverride, source)
 
25
  getOpencodeBaseUrl,
26
  } from 'src/utils/model/providers.js'
27
  import { getProxyFetchOptions } from 'src/utils/proxy.js'
28
+ import { createOpenCodeFetchOverride, fetchOpencodeModels } from './opencodeClient.js'
29
  import {
30
  getIsNonInteractiveSession,
31
  getSessionId,
 
148
  // For OpenCode provider, use custom fetch to convert Anthropic format to OpenAI format
149
  const provider = getAPIProvider()
150
  let opencodeFetchOverride: ClientOptions['fetch'] | undefined
151
+ if (provider === 'opencode') {
152
+ const resolvedModel = model || getOpenCodeModelName() || 'big-pickle'
153
  // Fetch models in background
154
+ void fetchOpencodeModels()
155
+ opencodeFetchOverride = createOpenCodeFetchOverride(resolvedModel)
156
  }
157
 
158
  const resolvedFetch = buildFetch(fetchOverride || opencodeFetchOverride, source)
src/services/api/opencodeClient.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  import { getOpenCodeApiKey, getOpenCodeModelName } from '../../utils/auth.js'
2
  import {
3
  convertAnthropicToolsToOpenAI,
@@ -31,7 +32,10 @@ export async function fetchOpencodeModels(): Promise<void> {
31
  }
32
 
33
  const res = await fetch(`${OPENCODE_BASE_URL}/models`, { headers })
34
- if (!res.ok) return
 
 
 
35
 
36
  const data = await res.json() as { data?: Array<{ id: string; name?: string }> }
37
  if (Array.isArray(data.data)) {
@@ -41,8 +45,8 @@ export async function fetchOpencodeModels(): Promise<void> {
41
  isFree: m.id.endsWith('-free') || FREE_MODEL_IDS.has(m.id),
42
  }))
43
  }
44
- } catch {
45
- // Ignore errors
46
  } finally {
47
  fetchPromise = null
48
  }
@@ -182,6 +186,7 @@ export function createOpenCodeFetchOverride(
182
  const apiKey = getOpenCodeApiKey() || ''
183
  const modelName = getOpenCodeModelName() || model || 'big-pickle'
184
  const endpoint = chatCompletionsUrl(apiKey ? OPENCODE_BASE_URL : OPENCODE_BASE_URL)
 
185
 
186
  return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
187
  const url = input instanceof URL ? input.href : typeof input === 'string' ? input : input.url
@@ -252,17 +257,23 @@ export function createOpenCodeFetchOverride(
252
  const headers: Record<string, string> = {
253
  'Content-Type': 'application/json',
254
  'User-Agent': 'claude-code/2.1.88',
 
 
 
255
  }
256
  if (apiKey) {
257
  headers.Authorization = `Bearer ${apiKey}`
258
  }
259
 
 
260
  const openaiResponse = await fetch(endpoint, {
261
  method: 'POST',
262
  headers,
263
  body: JSON.stringify(requestBody),
264
  signal: init?.signal,
265
  })
 
 
266
 
267
  if (!openaiResponse.ok) {
268
  return openaiResponse
@@ -357,14 +368,43 @@ function convertOpenAIStreamToAnthropicWithReasoning(
357
  const encoder = new TextEncoder()
358
  const decoder = new TextDecoder()
359
 
360
- let messageId = `msg_${Date.now()}`
361
  let contentIndex = 0
362
  let hasStartedContent = false
363
  let hasReasoningBlock = false
 
364
  let currentToolCallIndex = -1
365
  const toolCalls: Map<number, { id: string; name: string; arguments: string }> = new Map()
366
  let totalOutputTokens = 0
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  return new ReadableStream({
369
  async start(controller) {
370
  const reader = openaiStream.getReader()
@@ -373,7 +413,12 @@ function convertOpenAIStreamToAnthropicWithReasoning(
373
  try {
374
  while (true) {
375
  const { done, value } = await reader.read()
376
- if (done) break
 
 
 
 
 
377
 
378
  buffer += decoder.decode(value, { stream: true })
379
 
@@ -384,20 +429,7 @@ function convertOpenAIStreamToAnthropicWithReasoning(
384
  if (!line.startsWith('data: ')) continue
385
  const data = line.slice(6).trim()
386
  if (data === '[DONE]') {
387
- if (hasStartedContent) {
388
- controller.enqueue(encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex - 1}}\n\n`))
389
- }
390
- for (const [idx, tc] of toolCalls) {
391
- controller.enqueue(
392
- encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex + idx}}\n\n`),
393
- )
394
- }
395
- controller.enqueue(
396
- encoder.encode(
397
- `event: message_delta\ndata: {"delta":{"stop_reason":"${toolCalls.size > 0 ? 'tool_use' : 'end_turn'}"},"usage":{"output_tokens":${totalOutputTokens}}}\n\n`,
398
- ),
399
- )
400
- controller.enqueue(encoder.encode('event: message_stop\ndata: {}\n\n'))
401
  return
402
  }
403
 
@@ -424,6 +456,16 @@ function convertOpenAIStreamToAnthropicWithReasoning(
424
  continue
425
  }
426
 
 
 
 
 
 
 
 
 
 
 
427
  if (chunk.usage?.completion_tokens) {
428
  totalOutputTokens = chunk.usage.completion_tokens
429
  }
@@ -491,7 +533,7 @@ function convertOpenAIStreamToAnthropicWithReasoning(
491
  hasStartedContent ? contentIndex + 1 + tc.index : contentIndex + tc.index
492
  controller.enqueue(
493
  encoder.encode(
494
- `event: content_block_start\ndata: {"index":${toolBlockIndex},"content_block":{"type":"text","text":""}}\n\n`,
495
  ),
496
  )
497
  } else if (tc.function?.arguments) {
@@ -509,17 +551,23 @@ function convertOpenAIStreamToAnthropicWithReasoning(
509
  encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex}}\n\n`),
510
  )
511
  }
512
- controller.enqueue(
513
- encoder.encode(
514
- `event: message_delta\ndata: {"delta":{"stop_reason":"${choice.finish_reason === 'tool_calls' ? 'tool_use' : 'end_turn'}"},"usage":{"output_tokens":${totalOutputTokens}}}\n\n`,
515
- ),
516
- )
517
- controller.enqueue(encoder.encode('event: message_stop\ndata: {}\n\n'))
518
  return
519
  }
520
  }
521
  }
522
  } finally {
 
 
 
 
 
 
 
 
 
 
 
523
  reader.releaseLock()
524
  controller.close()
525
  }
 
1
+ import { randomUUID } from 'crypto'
2
  import { getOpenCodeApiKey, getOpenCodeModelName } from '../../utils/auth.js'
3
  import {
4
  convertAnthropicToolsToOpenAI,
 
32
  }
33
 
34
  const res = await fetch(`${OPENCODE_BASE_URL}/models`, { headers })
35
+ if (!res.ok) {
36
+ console.error(`[opencodeClient] Failed to fetch models: ${res.status} ${res.statusText}`)
37
+ return
38
+ }
39
 
40
  const data = await res.json() as { data?: Array<{ id: string; name?: string }> }
41
  if (Array.isArray(data.data)) {
 
45
  isFree: m.id.endsWith('-free') || FREE_MODEL_IDS.has(m.id),
46
  }))
47
  }
48
+ } catch (error) {
49
+ console.error('[opencodeClient] Error fetching models:', error)
50
  } finally {
51
  fetchPromise = null
52
  }
 
186
  const apiKey = getOpenCodeApiKey() || ''
187
  const modelName = getOpenCodeModelName() || model || 'big-pickle'
188
  const endpoint = chatCompletionsUrl(apiKey ? OPENCODE_BASE_URL : OPENCODE_BASE_URL)
189
+ const sessionId = randomUUID()
190
 
191
  return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
192
  const url = input instanceof URL ? input.href : typeof input === 'string' ? input : input.url
 
257
  const headers: Record<string, string> = {
258
  'Content-Type': 'application/json',
259
  'User-Agent': 'claude-code/2.1.88',
260
+ 'x-opencode-session': sessionId,
261
+ 'x-opencode-request': randomUUID(),
262
+ 'x-opencode-client': 'better-clawd/2.1.87',
263
  }
264
  if (apiKey) {
265
  headers.Authorization = `Bearer ${apiKey}`
266
  }
267
 
268
+ const t0 = Date.now()
269
  const openaiResponse = await fetch(endpoint, {
270
  method: 'POST',
271
  headers,
272
  body: JSON.stringify(requestBody),
273
  signal: init?.signal,
274
  })
275
+ const t1 = Date.now()
276
+ console.error(`[opencodeClient] ${isStreaming ? 'stream' : 'non-stream'} fetch took ${t1 - t0}ms, status=${openaiResponse.status}`)
277
 
278
  if (!openaiResponse.ok) {
279
  return openaiResponse
 
368
  const encoder = new TextEncoder()
369
  const decoder = new TextDecoder()
370
 
371
+ const messageId = `msg_${Date.now()}`
372
  let contentIndex = 0
373
  let hasStartedContent = false
374
  let hasReasoningBlock = false
375
+ let hasSentMessageStart = false
376
  let currentToolCallIndex = -1
377
  const toolCalls: Map<number, { id: string; name: string; arguments: string }> = new Map()
378
  let totalOutputTokens = 0
379
 
380
+ function sendMessageStart(controller: ReadableStreamDefaultController<Uint8Array>): void {
381
+ if (hasSentMessageStart) return
382
+ hasSentMessageStart = true
383
+ controller.enqueue(
384
+ encoder.encode(
385
+ `event: message_start\ndata: {"type":"message_start","message":{"id":"${messageId}","type":"message","role":"assistant","content":[],"model":"${model}","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}\n\n`,
386
+ ),
387
+ )
388
+ }
389
+
390
+ function sendStreamEnd(controller: ReadableStreamDefaultController<Uint8Array>, stopReason?: string): void {
391
+ if (!hasSentMessageStart) return
392
+ if (hasStartedContent) {
393
+ controller.enqueue(encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex - 1}}\n\n`))
394
+ }
395
+ for (const [idx] of toolCalls) {
396
+ controller.enqueue(
397
+ encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex + idx}}\n\n`),
398
+ )
399
+ }
400
+ controller.enqueue(
401
+ encoder.encode(
402
+ `event: message_delta\ndata: {"delta":{"stop_reason":"${stopReason || (toolCalls.size > 0 ? 'tool_use' : 'end_turn')}"},"usage":{"output_tokens":${totalOutputTokens}}}\n\n`,
403
+ ),
404
+ )
405
+ controller.enqueue(encoder.encode('event: message_stop\ndata: {}\n\n'))
406
+ }
407
+
408
  return new ReadableStream({
409
  async start(controller) {
410
  const reader = openaiStream.getReader()
 
413
  try {
414
  while (true) {
415
  const { done, value } = await reader.read()
416
+ if (done) {
417
+ if (hasSentMessageStart) {
418
+ sendStreamEnd(controller)
419
+ }
420
+ break
421
+ }
422
 
423
  buffer += decoder.decode(value, { stream: true })
424
 
 
429
  if (!line.startsWith('data: ')) continue
430
  const data = line.slice(6).trim()
431
  if (data === '[DONE]') {
432
+ sendStreamEnd(controller)
 
 
 
 
 
 
 
 
 
 
 
 
 
433
  return
434
  }
435
 
 
456
  continue
457
  }
458
 
459
+ if (!hasSentMessageStart) {
460
+ const inputTokens = chunk.usage?.prompt_tokens || 0
461
+ controller.enqueue(
462
+ encoder.encode(
463
+ `event: message_start\ndata: {"type":"message_start","message":{"id":"${messageId}","type":"message","role":"assistant","content":[],"model":"${model}","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":${inputTokens},"output_tokens":0}}}\n\n`,
464
+ ),
465
+ )
466
+ hasSentMessageStart = true
467
+ }
468
+
469
  if (chunk.usage?.completion_tokens) {
470
  totalOutputTokens = chunk.usage.completion_tokens
471
  }
 
533
  hasStartedContent ? contentIndex + 1 + tc.index : contentIndex + tc.index
534
  controller.enqueue(
535
  encoder.encode(
536
+ `event: content_block_start\ndata: {"index":${toolBlockIndex},"content_block":{"type":"tool_use","id":"${tc.id}","name":"${tc.function?.name || ''}","input":{}}}\n\n`,
537
  ),
538
  )
539
  } else if (tc.function?.arguments) {
 
551
  encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex}}\n\n`),
552
  )
553
  }
554
+ sendStreamEnd(controller, choice.finish_reason === 'tool_calls' ? 'tool_use' : 'end_turn')
 
 
 
 
 
555
  return
556
  }
557
  }
558
  }
559
  } finally {
560
+ if (!hasSentMessageStart) {
561
+ controller.enqueue(
562
+ encoder.encode(
563
+ `event: message_start\ndata: {"type":"message_start","message":{"id":"${messageId}","type":"message","role":"assistant","content":[],"model":"${model}","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}\n\n`,
564
+ ),
565
+ )
566
+ controller.enqueue(
567
+ encoder.encode('event: message_delta\ndata: {"delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":0}}\n\n'),
568
+ )
569
+ controller.enqueue(encoder.encode('event: message_stop\ndata: {}\n\n'))
570
+ }
571
  reader.releaseLock()
572
  controller.close()
573
  }
src/utils/model/modelOptions.ts CHANGED
@@ -95,18 +95,30 @@ export function getDefaultOptionForUser(fastMode = false): ModelOption {
95
  }
96
 
97
  // Check if we should use OpenCode Zen model
98
- if (isOpenCode || provider === null) {
 
 
 
 
 
 
 
 
 
 
 
 
99
  try {
100
  // eslint-disable-next-line @typescript-eslint/no-require-imports
101
  const { readFileSync } = require('fs') as typeof import('fs')
102
  const { getGlobalClaudeFile } = require('../env.js') as typeof import('../env.js')
103
  const raw = readFileSync(getGlobalClaudeFile(), 'utf8')
104
- const config = JSON.parse(raw) as {
105
  authProvider?: string
106
  openCodeModelName?: string
107
  }
108
- if (config.authProvider === 'opencode') {
109
- const currentModel = config.openCodeModelName || 'big-pickle'
110
  const displayName = currentModel === 'big-pickle' ? 'Big Pickle' : currentModel
111
  return {
112
  value: null,
@@ -500,7 +512,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] {
500
 
501
  if (models && Array.isArray(models) && models.length > 0) {
502
  const filtered = hasApiKey
503
- ? models.filter(m => !m.isFree)
504
  : models.filter(m => m.isFree)
505
 
506
  if (filtered.length > 0) {
 
95
  }
96
 
97
  // Check if we should use OpenCode Zen model
98
+ if (isOpenCode) {
99
+ const currentModel = getOpenCodeModelName() || 'big-pickle'
100
+ const displayName = currentModel === 'big-pickle' ? 'Big Pickle' : currentModel
101
+ return {
102
+ value: null,
103
+ label: 'Default (recommended)',
104
+ description: `Use the default OpenCode Zen model (currently ${displayName})`,
105
+ descriptionForModel: `Default OpenCode Zen model (currently ${displayName})`,
106
+ }
107
+ }
108
+
109
+ // Fallback: when provider cache is stale (null), check config file directly
110
+ if (provider === null) {
111
  try {
112
  // eslint-disable-next-line @typescript-eslint/no-require-imports
113
  const { readFileSync } = require('fs') as typeof import('fs')
114
  const { getGlobalClaudeFile } = require('../env.js') as typeof import('../env.js')
115
  const raw = readFileSync(getGlobalClaudeFile(), 'utf8')
116
+ const fileConfig = JSON.parse(raw) as {
117
  authProvider?: string
118
  openCodeModelName?: string
119
  }
120
+ if (fileConfig.authProvider === 'opencode') {
121
+ const currentModel = fileConfig.openCodeModelName || 'big-pickle'
122
  const displayName = currentModel === 'big-pickle' ? 'Big Pickle' : currentModel
123
  return {
124
  value: null,
 
512
 
513
  if (models && Array.isArray(models) && models.length > 0) {
514
  const filtered = hasApiKey
515
+ ? models
516
  : models.filter(m => m.isFree)
517
 
518
  if (filtered.length > 0) {