chenbhao commited on
Commit
92e96ba
Β·
1 Parent(s): 609b0a7

fix: nvidia provider chat

Browse files
desktop/src/lib/tuiConversation.ts CHANGED
@@ -1,8 +1,9 @@
1
  /**
2
  * TUI-style conversation module for desktop.
3
  *
4
- * Reuses TUI's approach of directly calling provider APIs via HTTP,
5
- * without going through the cc-haha sidecar or CLI subprocess.
 
6
  *
7
  * Supports all provider types:
8
  * - Anthropic protocol: firstParty, openrouter, openai, local
@@ -12,6 +13,7 @@
12
  */
13
 
14
  import { getTuiConfig } from '../api/config'
 
15
 
16
  // ─── Types ──────────────────────────────────────────────────────────────────
17
 
@@ -151,18 +153,24 @@ async function* streamOpenAI(
151
  stream: true,
152
  }
153
 
 
 
 
154
  let res: Response
155
  try {
156
- res = await fetch(`${config.baseUrl.replace(/\/$/, '')}/chat/completions`, {
157
  method: 'POST',
158
- headers: openaiHeaders(config.apiKey),
 
 
 
159
  body: JSON.stringify(body),
160
  signal,
161
  })
162
  } catch (err) {
163
  const name = err instanceof TypeError ? err.name : ''
164
  const msg = err instanceof Error ? err.message : String(err)
165
- console.error('[tuiConversation] OpenAI fetch failed:', { name, message: msg, url: config.baseUrl, model: config.model })
166
  yield { type: 'error', message: `Network error: ${msg}${name ? ` (${name})` : ''}` }
167
  yield { type: 'done' }
168
  return
@@ -184,7 +192,6 @@ async function* streamOpenAI(
184
  const decoder = new TextDecoder()
185
  const reader = res.body.getReader()
186
  let buffer = ''
187
- let hasReceivedContent = false
188
  const IDLE_TIMEOUT_MS = 3000
189
 
190
  yield { type: 'content_block_start', index: 0, blockType: 'text' }
@@ -192,9 +199,6 @@ async function* streamOpenAI(
192
  while (true) {
193
  if (signal?.aborted) break
194
 
195
- // Race between read and idle timeout. Some local providers (Ollama, LM Studio,
196
- // etc.) never send [DONE] or close the SSE connection after the response is
197
- // complete. The timeout lets us detect this and finish cleanly.
198
  const readPromise = reader.read()
199
  const timeoutPromise = new Promise<{ done: true; value: undefined }>((resolve) =>
200
  setTimeout(() => resolve({ done: true, value: undefined }), IDLE_TIMEOUT_MS),
@@ -272,18 +276,21 @@ export async function* sendMessage(
272
  }
273
  if (options?.system) body.system = options.system
274
 
 
 
 
275
  let res: Response
276
  try {
277
- res = await fetch(`${config.baseUrl.replace(/\/$/, '')}/messages`, {
278
  method: 'POST',
279
- headers: anthropicHeaders(config.apiKey),
280
  body: JSON.stringify(body),
281
  signal: options?.signal,
282
  })
283
  } catch (err) {
284
  const name = err instanceof TypeError ? err.name : ''
285
  const msg = err instanceof Error ? err.message : String(err)
286
- console.error('[tuiConversation] Anthropic fetch failed:', { name, message: msg, url: config.baseUrl, model: config.model })
287
  yield { type: 'error', message: `Network error: ${msg}${name ? ` (${name})` : ''}` }
288
  yield { type: 'done' }
289
  return
 
1
  /**
2
  * TUI-style conversation module for desktop.
3
  *
4
+ * All requests go through the sidecar proxy (/api/cli-proxy) to avoid
5
+ * browser CORS restrictions. The sidecar reads ~/.claude.json for auth
6
+ * and proxies to provider APIs server-side.
7
  *
8
  * Supports all provider types:
9
  * - Anthropic protocol: firstParty, openrouter, openai, local
 
13
  */
14
 
15
  import { getTuiConfig } from '../api/config'
16
+ import { getBaseUrl } from '../api/client'
17
 
18
  // ─── Types ──────────────────────────────────────────────────────────────────
19
 
 
153
  stream: true,
154
  }
155
 
156
+ // Proxy through sidecar to avoid browser CORS
157
+ const proxyUrl = `${getBaseUrl()}/api/cli-proxy/chat/completions`
158
+
159
  let res: Response
160
  try {
161
+ res = await fetch(proxyUrl, {
162
  method: 'POST',
163
+ headers: {
164
+ 'Content-Type': 'application/json',
165
+ // API key is read by sidecar from ~/.claude.json
166
+ },
167
  body: JSON.stringify(body),
168
  signal,
169
  })
170
  } catch (err) {
171
  const name = err instanceof TypeError ? err.name : ''
172
  const msg = err instanceof Error ? err.message : String(err)
173
+ console.error('[tuiConversation] OpenAI fetch failed:', { name, message: msg, url: proxyUrl, model: config.model })
174
  yield { type: 'error', message: `Network error: ${msg}${name ? ` (${name})` : ''}` }
175
  yield { type: 'done' }
176
  return
 
192
  const decoder = new TextDecoder()
193
  const reader = res.body.getReader()
194
  let buffer = ''
 
195
  const IDLE_TIMEOUT_MS = 3000
196
 
197
  yield { type: 'content_block_start', index: 0, blockType: 'text' }
 
199
  while (true) {
200
  if (signal?.aborted) break
201
 
 
 
 
202
  const readPromise = reader.read()
203
  const timeoutPromise = new Promise<{ done: true; value: undefined }>((resolve) =>
204
  setTimeout(() => resolve({ done: true, value: undefined }), IDLE_TIMEOUT_MS),
 
276
  }
277
  if (options?.system) body.system = options.system
278
 
279
+ // Proxy through sidecar to avoid browser CORS
280
+ const proxyUrl = `${getBaseUrl()}/api/cli-proxy/messages`
281
+
282
  let res: Response
283
  try {
284
+ res = await fetch(proxyUrl, {
285
  method: 'POST',
286
+ headers: { 'Content-Type': 'application/json' },
287
  body: JSON.stringify(body),
288
  signal: options?.signal,
289
  })
290
  } catch (err) {
291
  const name = err instanceof TypeError ? err.name : ''
292
  const msg = err instanceof Error ? err.message : String(err)
293
+ console.error('[tuiConversation] Anthropic fetch failed:', { name, message: msg, url: proxyUrl, model: config.model })
294
  yield { type: 'error', message: `Network error: ${msg}${name ? ` (${name})` : ''}` }
295
  yield { type: 'done' }
296
  return
src/server/api/cli-proxy.ts ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * CLI Proxy API β€” proxies chat requests for desktop WebView.
3
+ *
4
+ * Desktop WebView cannot call provider APIs directly due to CORS restrictions.
5
+ * This endpoint reads auth config from ~/.claude.json and proxies the request
6
+ * server-side (no CORS).
7
+ *
8
+ * POST /api/cli-proxy/messages β€” Anthropic Messages API (streaming/non-streaming)
9
+ * POST /api/cli-proxy/chat/completions β€” OpenAI Chat Completions (streaming/non-streaming)
10
+ */
11
+
12
+ import { readCliAuthProvider } from './models.js'
13
+
14
+ type ProviderClientConfig = {
15
+ baseUrl: string
16
+ apiKey: string
17
+ model: string
18
+ protocol: 'anthropic' | 'openai'
19
+ }
20
+
21
+ // Map authProvider to API config
22
+ async function getCliProviderConfig(): Promise<ProviderClientConfig | null> {
23
+ const config = await readCliAuthProvider()
24
+ const authProvider = config?.authProvider
25
+
26
+ switch (authProvider) {
27
+ case 'openrouter': {
28
+ const apiKey = config?.openRouterApiKey as string | undefined
29
+ if (!apiKey) return null
30
+ const model = (config?.openRouterModel as string) || 'anthropic/claude-sonnet-4-6'
31
+ return {
32
+ baseUrl: (config?.openRouterBaseUrl as string) || 'https://openrouter.ai/api/v1',
33
+ apiKey,
34
+ model,
35
+ protocol: 'anthropic',
36
+ }
37
+ }
38
+
39
+ case 'nvidia': {
40
+ const apiKey = config?.nvidiaApiKey as string | undefined
41
+ if (!apiKey) return null
42
+ const model = (config?.nvidiaModel as string) || 'nvidia/llama-3.1-nemotron-70b-instruct'
43
+ return {
44
+ baseUrl: (config?.nvidiaBaseUrl as string) || 'https://integrate.api.nvidia.com/v1',
45
+ apiKey,
46
+ model,
47
+ protocol: 'openai',
48
+ }
49
+ }
50
+
51
+ case 'opencode': {
52
+ const apiKey = (config?.openCodeApiKey as string) || 'public'
53
+ const model = (config?.openCodeModelName as string) || 'big-pickle'
54
+ return {
55
+ baseUrl: 'https://opencode.ai/zen/v1',
56
+ apiKey,
57
+ model,
58
+ protocol: 'openai',
59
+ }
60
+ }
61
+
62
+ case 'openai': {
63
+ const apiKey = (config?.openAiApiKey as string) || (config?.openAiAccessToken as string)
64
+ if (!apiKey) return null
65
+ const model = (config?.openAiModel as string) || 'gpt-5.4-codex'
66
+ return {
67
+ baseUrl: (config?.openAiBaseUrl as string) || 'https://api.openai.com/v1',
68
+ apiKey,
69
+ model,
70
+ protocol: 'openai',
71
+ }
72
+ }
73
+
74
+ case 'local': {
75
+ const baseUrl = config?.localBaseUrl as string
76
+ if (!baseUrl) return null
77
+ const model = (config?.localModelName as string) || 'local-model'
78
+ return {
79
+ baseUrl,
80
+ apiKey: 'local-model',
81
+ model,
82
+ protocol: 'openai',
83
+ }
84
+ }
85
+
86
+ case 'anthropic': {
87
+ const apiKey = config?.anthropicApiKey as string | undefined
88
+ if (!apiKey) return null
89
+ const model = (config?.anthropicModel as string) || 'claude-sonnet-4-6'
90
+ return {
91
+ baseUrl: (config?.anthropicBaseUrl as string) || 'https://api.anthropic.com/v1',
92
+ apiKey,
93
+ model,
94
+ protocol: 'anthropic',
95
+ }
96
+ }
97
+
98
+ default:
99
+ return null
100
+ }
101
+ }
102
+
103
+ export async function handleCliProxyApi(
104
+ req: Request,
105
+ url: URL,
106
+ segments: string[],
107
+ ): Promise<Response> {
108
+ const resource = segments[2] // 'messages' | 'chat' | ...
109
+
110
+ if (req.method !== 'POST') {
111
+ return Response.json({ error: 'Method not allowed' }, { status: 405 })
112
+ }
113
+
114
+ const providerConfig = await getCliProviderConfig()
115
+ if (!providerConfig) {
116
+ return Response.json(
117
+ { type: 'error', error: { type: 'authentication_error', message: 'No CLI auth configured. Run /login first.' } },
118
+ { status: 401 },
119
+ )
120
+ }
121
+
122
+ if (resource === 'messages') {
123
+ // Anthropic Messages API
124
+ return handleAnthropicMessages(req, providerConfig)
125
+ }
126
+
127
+ if (resource === 'chat' && segments[3] === 'completions') {
128
+ // OpenAI Chat Completions API
129
+ return handleOpenAiChat(req, providerConfig)
130
+ }
131
+
132
+ return Response.json({ error: 'Not found' }, { status: 404 })
133
+ }
134
+
135
+ async function handleAnthropicMessages(incomingReq: Request, cfg: ProviderClientConfig): Promise<Response> {
136
+ let body: Record<string, unknown>
137
+ try {
138
+ body = (await incomingReq.json()) as Record<string, unknown>
139
+ } catch {
140
+ return Response.json({ type: 'error', error: { type: 'invalid_request_error', message: 'Invalid JSON' } }, { status: 400 })
141
+ }
142
+
143
+ // Override model from config (desktop may send a different one)
144
+ const model = (body.model as string) || cfg.model
145
+
146
+ const url = `${cfg.baseUrl.replace(/\/$/, '')}/messages`
147
+ const isStream = body.stream === true
148
+
149
+ try {
150
+ const upstream = await fetch(url, {
151
+ method: 'POST',
152
+ headers: {
153
+ 'Content-Type': 'application/json',
154
+ 'x-api-key': cfg.apiKey,
155
+ 'anthropic-version': '2023-06-01',
156
+ },
157
+ body: JSON.stringify({ ...body, model }),
158
+ signal: AbortSignal.timeout(120_000),
159
+ })
160
+
161
+ if (!upstream.ok) {
162
+ const errText = await upstream.text().catch(() => '')
163
+ return Response.json(
164
+ { type: 'error', error: { type: 'api_error', message: `Upstream ${upstream.status}: ${errText.slice(0, 500)}` } },
165
+ { status: upstream.status },
166
+ )
167
+ }
168
+
169
+ if (isStream && upstream.body) {
170
+ return new Response(upstream.body, {
171
+ status: 200,
172
+ headers: {
173
+ 'Content-Type': 'text/event-stream',
174
+ 'Cache-Control': 'no-cache',
175
+ 'Connection': 'keep-alive',
176
+ },
177
+ })
178
+ }
179
+
180
+ const data = await upstream.json()
181
+ return Response.json(data)
182
+ } catch (err) {
183
+ return Response.json(
184
+ { type: 'error', error: { type: 'api_error', message: err instanceof Error ? err.message : String(err) } },
185
+ { status: 502 },
186
+ )
187
+ }
188
+ }
189
+
190
+ async function handleOpenAiChat(incomingReq: Request, cfg: ProviderClientConfig): Promise<Response> {
191
+ let body: Record<string, unknown>
192
+ try {
193
+ body = (await incomingReq.json()) as Record<string, unknown>
194
+ } catch {
195
+ return Response.json({ error: 'Invalid JSON' }, { status: 400 })
196
+ }
197
+
198
+ const model = (body.model as string) || cfg.model
199
+ const url = `${cfg.baseUrl.replace(/\/$/, '')}/chat/completions`
200
+ const isStream = body.stream === true
201
+
202
+ try {
203
+ const upstream = await fetch(url, {
204
+ method: 'POST',
205
+ headers: {
206
+ 'Content-Type': 'application/json',
207
+ Authorization: `Bearer ${cfg.apiKey}`,
208
+ },
209
+ body: JSON.stringify({ ...body, model }),
210
+ signal: AbortSignal.timeout(120_000),
211
+ })
212
+
213
+ if (!upstream.ok) {
214
+ const errText = await upstream.text().catch(() => '')
215
+ return Response.json(
216
+ { error: `Upstream ${upstream.status}: ${errText.slice(0, 500)}` },
217
+ { status: upstream.status },
218
+ )
219
+ }
220
+
221
+ if (isStream && upstream.body) {
222
+ return new Response(upstream.body, {
223
+ status: 200,
224
+ headers: {
225
+ 'Content-Type': 'text/event-stream',
226
+ 'Cache-Control': 'no-cache',
227
+ 'Connection': 'keep-alive',
228
+ },
229
+ })
230
+ }
231
+
232
+ const data = await upstream.json()
233
+ return Response.json(data)
234
+ } catch (err) {
235
+ return Response.json(
236
+ { error: err instanceof Error ? err.message : String(err) },
237
+ { status: 502 },
238
+ )
239
+ }
240
+ }
src/server/api/models.ts CHANGED
@@ -316,7 +316,7 @@ export async function handleModelsApi(
316
 
317
  // ─── Handlers ─────────────────────────────────────────────────────────────────
318
 
319
- async function readCliAuthProvider(): Promise<{
320
  authProvider?: 'anthropic' | 'openai' | 'openrouter' | 'local' | 'opencode' | 'nvidia'
321
  } | null> {
322
  try {
 
316
 
317
  // ─── Handlers ─────────────────────────────────────────────────────────────────
318
 
319
+ export async function readCliAuthProvider(): Promise<{
320
  authProvider?: 'anthropic' | 'openai' | 'openrouter' | 'local' | 'opencode' | 'nvidia'
321
  } | null> {
322
  try {
src/server/router.ts CHANGED
@@ -28,6 +28,7 @@ import { handleOpenTargetsApi } from './api/open-targets.js'
28
  import { handleMemoryApi } from './api/memory.js'
29
  import { handleDesktopUiApi } from './api/desktop-ui.js'
30
  import { handleCliAuthApi } from './api/cli-auth.js'
 
31
 
32
  export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
33
  const path = url.pathname
@@ -123,6 +124,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
123
  case 'cli-auth':
124
  return handleCliAuthApi(req, url, segments)
125
 
 
 
 
126
  case 'filesystem':
127
  return handleFilesystemRoute(url.pathname, url)
128
 
 
28
  import { handleMemoryApi } from './api/memory.js'
29
  import { handleDesktopUiApi } from './api/desktop-ui.js'
30
  import { handleCliAuthApi } from './api/cli-auth.js'
31
+ import { handleCliProxyApi } from './api/cli-proxy.js'
32
 
33
  export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
34
  const path = url.pathname
 
124
  case 'cli-auth':
125
  return handleCliAuthApi(req, url, segments)
126
 
127
+ case 'cli-proxy':
128
+ return handleCliProxyApi(req, url, segments)
129
+
130
  case 'filesystem':
131
  return handleFilesystemRoute(url.pathname, url)
132