@gotcos/glasses-server 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cos-profile.example.json +7 -0
- package/.env.example +44 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/bin/cli.cjs +203 -0
- package/package.json +53 -0
- package/server/env.ts +26 -0
- package/server/index.ts +211 -0
- package/server/lib/archive-budget.ts +65 -0
- package/server/lib/archive.ts +414 -0
- package/server/lib/atomic-fs.ts +50 -0
- package/server/lib/audio-enhance.ts +87 -0
- package/server/lib/claude-bridge.ts +682 -0
- package/server/lib/claude-circuit.ts +52 -0
- package/server/lib/claude-run-ledger.ts +279 -0
- package/server/lib/codex-bridge.ts +476 -0
- package/server/lib/codex-engine-sessions.ts +140 -0
- package/server/lib/codex-run-ledger.ts +298 -0
- package/server/lib/context-builder.ts +210 -0
- package/server/lib/conversation.ts +587 -0
- package/server/lib/data-dir.ts +20 -0
- package/server/lib/display-bus.ts +21 -0
- package/server/lib/display-format.ts +23 -0
- package/server/lib/fuzzy-correct.ts +286 -0
- package/server/lib/hallucination-filter.ts +469 -0
- package/server/lib/local-day.ts +13 -0
- package/server/lib/model-router.ts +38 -0
- package/server/lib/openai-key.ts +155 -0
- package/server/lib/openai-whisper-budget.ts +170 -0
- package/server/lib/profile.ts +94 -0
- package/server/lib/python-bridge.ts +84 -0
- package/server/lib/response-cache.ts +138 -0
- package/server/lib/session-cache-writer.ts +266 -0
- package/server/lib/session-log.ts +162 -0
- package/server/lib/speaker-embeddings.ts +578 -0
- package/server/lib/telegram-notify.ts +85 -0
- package/server/lib/token-audit.ts +50 -0
- package/server/lib/transcribe-audio.ts +187 -0
- package/server/lib/utils.ts +5 -0
- package/server/lib/vad-silero.ts +179 -0
- package/server/lib/whisper-local.ts +697 -0
- package/server/routes/diag.ts +115 -0
- package/server/routes/display.ts +65 -0
- package/server/routes/health.ts +128 -0
- package/server/routes/openai-compat.ts +446 -0
- package/server/routes/openai-key.ts +121 -0
- package/server/routes/query.ts +121 -0
- package/server/routes/transcribe-stream.ts +1090 -0
- package/server/routes/transcribe.ts +55 -0
- package/shared/model-preference.ts +81 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
// OpenAI-compatible /v1/chat/completions endpoint
|
|
2
|
+
// Adapter for Even Realities "Add Agent" and any OpenAI-compatible client.
|
|
3
|
+
// Accepts standard OpenAI format, routes through COS model-router, returns OpenAI format.
|
|
4
|
+
// Supports both streaming (SSE) and non-streaming responses.
|
|
5
|
+
|
|
6
|
+
import { Router } from 'express'
|
|
7
|
+
import { preWarmCLI, logLatency } from '../lib/claude-bridge.js'
|
|
8
|
+
import { callModelStreaming } from '../lib/model-router.js'
|
|
9
|
+
import { normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
|
|
10
|
+
import { tryInstantResponse } from '../lib/response-cache.js'
|
|
11
|
+
import crypto from 'node:crypto'
|
|
12
|
+
|
|
13
|
+
export const openaiCompatRouter = Router()
|
|
14
|
+
|
|
15
|
+
// ─── Dedup guard — prevent Even from doubling server load ───
|
|
16
|
+
// Maps query text → { promise, timestamp } for in-flight requests.
|
|
17
|
+
// If the same query arrives within 2s of a pending request, reuse the result.
|
|
18
|
+
const inflightQueries = new Map<string, { promise: Promise<string>; timestamp: number }>()
|
|
19
|
+
const DEDUP_WINDOW_MS = 2000
|
|
20
|
+
|
|
21
|
+
function cleanupInflight() {
|
|
22
|
+
const now = Date.now()
|
|
23
|
+
for (const [key, entry] of inflightQueries) {
|
|
24
|
+
if (now - entry.timestamp > 30000) inflightQueries.delete(key) // 30s max
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Daily persistent session ───
|
|
29
|
+
// Session persists across all G2 queries for the entire day, building context
|
|
30
|
+
// as the user explores concepts, checks facts, follows threads.
|
|
31
|
+
// Auto-resets at midnight to prevent context rot.
|
|
32
|
+
// Say "reset session" or "new session" to force reset.
|
|
33
|
+
let g2SessionId: string | undefined = undefined
|
|
34
|
+
let g2SessionDate: string | undefined = undefined // YYYY-MM-DD of current session
|
|
35
|
+
|
|
36
|
+
function getOrResetG2Session(): string | undefined {
|
|
37
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
38
|
+
if (g2SessionDate !== today) {
|
|
39
|
+
// New day — reset session to prevent context rot
|
|
40
|
+
g2SessionId = undefined
|
|
41
|
+
g2SessionDate = today
|
|
42
|
+
console.log(`[g2] Daily session reset (${today})`)
|
|
43
|
+
// Lazy pre-warm: fire-and-forget so next query has a warm CLI session
|
|
44
|
+
preWarmCLI().catch(() => {})
|
|
45
|
+
}
|
|
46
|
+
return g2SessionId
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function shouldResetSession(query: string): boolean {
|
|
50
|
+
return /\b(reset session|new session|fresh start|clear context|start over)\b/i.test(query)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Optional: validate Bearer token against COS_API_TOKEN
|
|
54
|
+
function validateAuth(req: any, res: any): boolean {
|
|
55
|
+
const cosToken = process.env.COS_API_TOKEN
|
|
56
|
+
if (!cosToken) return true // No token configured = open access
|
|
57
|
+
|
|
58
|
+
const auth = req.headers['authorization']
|
|
59
|
+
if (!auth || !auth.startsWith('Bearer ')) {
|
|
60
|
+
res.status(401).json({ error: { message: 'Missing Bearer token', type: 'invalid_request_error' } })
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const token = auth.slice(7)
|
|
65
|
+
if (token !== cosToken) {
|
|
66
|
+
res.status(401).json({ error: { message: 'Invalid token', type: 'invalid_request_error' } })
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Resolve model from OpenAI-compatible model ids. Defaults to Haiku for
|
|
74
|
+
// "Hey Even" speed; Opus/Sonnet/Haiku/Codex High can be explicitly selected.
|
|
75
|
+
function resolveModel(model?: string, _query?: string): ModelPreference {
|
|
76
|
+
const normalized = normalizeModelPreference(model)
|
|
77
|
+
if (normalized) return normalized
|
|
78
|
+
if (model === 'cos-opus') return 'opus'
|
|
79
|
+
if (model === 'cos-sonnet') return 'sonnet'
|
|
80
|
+
if (model === 'cos-haiku') return 'haiku'
|
|
81
|
+
if (model === 'cos-codex-high' || model === 'cos-codex') return 'codex-high'
|
|
82
|
+
return normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL) ?? 'haiku'
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const MODEL_NAMES: Record<ModelPreference, string> = {
|
|
86
|
+
opus: 'cos-opus',
|
|
87
|
+
sonnet: 'cos-sonnet',
|
|
88
|
+
haiku: 'cos-haiku',
|
|
89
|
+
'codex-high': 'cos-codex-high',
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Extract the user's latest message from the OpenAI messages array
|
|
93
|
+
function extractUserQuery(messages: Array<{ role: string; content: string }>): string {
|
|
94
|
+
// Find the last user message
|
|
95
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
96
|
+
if (messages[i].role === 'user' && messages[i].content) {
|
|
97
|
+
return messages[i].content
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return ''
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
|
|
104
|
+
if (!validateAuth(req, res)) return
|
|
105
|
+
|
|
106
|
+
const { messages, stream, model } = req.body
|
|
107
|
+
|
|
108
|
+
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
|
109
|
+
return res.status(400).json({
|
|
110
|
+
error: { message: 'messages array required', type: 'invalid_request_error' },
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let query = extractUserQuery(messages)
|
|
115
|
+
if (!query) {
|
|
116
|
+
return res.status(400).json({
|
|
117
|
+
error: { message: 'No user message found', type: 'invalid_request_error' },
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
// Log request entry — tells us if Even sends stream: true or false
|
|
123
|
+
console.log(`[g2] Request: stream=${!!stream}, query="${query.slice(0, 50)}"`)
|
|
124
|
+
|
|
125
|
+
const requestReceivedAt = Date.now()
|
|
126
|
+
const resolvedModel = resolveModel(model, query)
|
|
127
|
+
const completionId = `chatcmpl-${crypto.randomUUID().slice(0, 12)}`
|
|
128
|
+
const timestamp = Math.floor(Date.now() / 1000)
|
|
129
|
+
const responseModel = MODEL_NAMES[resolvedModel]
|
|
130
|
+
|
|
131
|
+
// ── Predictive response cache — bypass Claude for common queries ──
|
|
132
|
+
const cached = tryInstantResponse(query)
|
|
133
|
+
if (cached) {
|
|
134
|
+
const ttfb = Date.now() - requestReceivedAt
|
|
135
|
+
logLatency({
|
|
136
|
+
timestamp: new Date().toISOString(),
|
|
137
|
+
query: query.slice(0, 50),
|
|
138
|
+
ttfb_ms: ttfb,
|
|
139
|
+
total_ms: ttfb,
|
|
140
|
+
model: 'cache',
|
|
141
|
+
resumed: false,
|
|
142
|
+
contextInjected: false,
|
|
143
|
+
cacheHit: true,
|
|
144
|
+
})
|
|
145
|
+
console.log(`[g2] Cache hit (${cached.pattern}): "${query}" → ${ttfb}ms`)
|
|
146
|
+
|
|
147
|
+
if (stream) {
|
|
148
|
+
res.writeHead(200, {
|
|
149
|
+
'Content-Type': 'text/event-stream',
|
|
150
|
+
'Cache-Control': 'no-cache',
|
|
151
|
+
'Connection': 'keep-alive',
|
|
152
|
+
'X-Accel-Buffering': 'no',
|
|
153
|
+
'Access-Control-Allow-Origin': '*',
|
|
154
|
+
})
|
|
155
|
+
res.flushHeaders()
|
|
156
|
+
const chunk = {
|
|
157
|
+
id: completionId,
|
|
158
|
+
object: 'chat.completion.chunk',
|
|
159
|
+
created: timestamp,
|
|
160
|
+
model: responseModel,
|
|
161
|
+
choices: [{ index: 0, delta: { content: cached.text }, finish_reason: null }],
|
|
162
|
+
}
|
|
163
|
+
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
|
164
|
+
const finalChunk = {
|
|
165
|
+
id: completionId,
|
|
166
|
+
object: 'chat.completion.chunk',
|
|
167
|
+
created: timestamp,
|
|
168
|
+
model: responseModel,
|
|
169
|
+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
|
170
|
+
}
|
|
171
|
+
res.write(`data: ${JSON.stringify(finalChunk)}\n\n`)
|
|
172
|
+
res.write('data: [DONE]\n\n')
|
|
173
|
+
res.end()
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Non-streaming cache response
|
|
178
|
+
return res.json({
|
|
179
|
+
id: completionId,
|
|
180
|
+
object: 'chat.completion',
|
|
181
|
+
created: timestamp,
|
|
182
|
+
model: responseModel,
|
|
183
|
+
choices: [{
|
|
184
|
+
index: 0,
|
|
185
|
+
message: { role: 'assistant', content: cached.text },
|
|
186
|
+
finish_reason: 'stop',
|
|
187
|
+
}],
|
|
188
|
+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Dedup guard — if same query is already in-flight within 2s, reuse result ──
|
|
193
|
+
cleanupInflight()
|
|
194
|
+
const dedupKey = `${resolvedModel}:${query.trim().toLowerCase()}`
|
|
195
|
+
const inflight = inflightQueries.get(dedupKey)
|
|
196
|
+
if (inflight && (Date.now() - inflight.timestamp) < DEDUP_WINDOW_MS) {
|
|
197
|
+
console.log(`[g2] Dedup hit: "${query.slice(0, 50)}" (waiting for in-flight result)`)
|
|
198
|
+
try {
|
|
199
|
+
const dedupResult = await inflight.promise
|
|
200
|
+
logLatency({
|
|
201
|
+
timestamp: new Date().toISOString(),
|
|
202
|
+
query: query.slice(0, 50),
|
|
203
|
+
ttfb_ms: Date.now() - requestReceivedAt,
|
|
204
|
+
total_ms: Date.now() - requestReceivedAt,
|
|
205
|
+
model: resolvedModel,
|
|
206
|
+
resumed: false,
|
|
207
|
+
contextInjected: false,
|
|
208
|
+
cacheHit: false,
|
|
209
|
+
deduped: true,
|
|
210
|
+
})
|
|
211
|
+
if (stream) {
|
|
212
|
+
res.writeHead(200, {
|
|
213
|
+
'Content-Type': 'text/event-stream',
|
|
214
|
+
'Cache-Control': 'no-cache',
|
|
215
|
+
'Connection': 'keep-alive',
|
|
216
|
+
'X-Accel-Buffering': 'no',
|
|
217
|
+
'Access-Control-Allow-Origin': '*',
|
|
218
|
+
})
|
|
219
|
+
res.flushHeaders()
|
|
220
|
+
const chunk = {
|
|
221
|
+
id: completionId,
|
|
222
|
+
object: 'chat.completion.chunk',
|
|
223
|
+
created: timestamp,
|
|
224
|
+
model: responseModel,
|
|
225
|
+
choices: [{ index: 0, delta: { content: dedupResult }, finish_reason: null }],
|
|
226
|
+
}
|
|
227
|
+
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
|
228
|
+
res.write(`data: ${JSON.stringify({ id: completionId, object: 'chat.completion.chunk', created: timestamp, model: responseModel, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })}\n\n`)
|
|
229
|
+
res.write('data: [DONE]\n\n')
|
|
230
|
+
res.end()
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
return res.json({
|
|
234
|
+
id: completionId,
|
|
235
|
+
object: 'chat.completion',
|
|
236
|
+
created: timestamp,
|
|
237
|
+
model: responseModel,
|
|
238
|
+
choices: [{ index: 0, message: { role: 'assistant', content: dedupResult }, finish_reason: 'stop' }],
|
|
239
|
+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
240
|
+
})
|
|
241
|
+
} catch {
|
|
242
|
+
// Original request failed — fall through to make a fresh request
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Register this query as in-flight for dedup
|
|
247
|
+
let resolveInflight: (text: string) => void
|
|
248
|
+
let rejectInflight: (err: any) => void
|
|
249
|
+
const inflightPromise = new Promise<string>((res, rej) => { resolveInflight = res; rejectInflight = rej })
|
|
250
|
+
inflightQueries.set(dedupKey, { promise: inflightPromise, timestamp: Date.now() })
|
|
251
|
+
|
|
252
|
+
// ── Streaming response (SSE) ──
|
|
253
|
+
if (stream) {
|
|
254
|
+
res.writeHead(200, {
|
|
255
|
+
'Content-Type': 'text/event-stream',
|
|
256
|
+
'Cache-Control': 'no-cache',
|
|
257
|
+
'Connection': 'keep-alive',
|
|
258
|
+
'X-Accel-Buffering': 'no',
|
|
259
|
+
'Access-Control-Allow-Origin': '*',
|
|
260
|
+
})
|
|
261
|
+
res.flushHeaders()
|
|
262
|
+
|
|
263
|
+
// Immediate keepalive — prevents ER app timeout while Claude processes
|
|
264
|
+
res.write(': keepalive\n\n')
|
|
265
|
+
|
|
266
|
+
let done = false
|
|
267
|
+
let firstChunkLogged = false
|
|
268
|
+
let actualTtfbMs = -1 // Captured at first chunk arrival
|
|
269
|
+
|
|
270
|
+
// Check for session reset command
|
|
271
|
+
if (shouldResetSession(query)) {
|
|
272
|
+
g2SessionId = undefined
|
|
273
|
+
g2SessionDate = new Date().toISOString().slice(0, 10)
|
|
274
|
+
console.log(`[g2] Manual session reset`)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const currentSessionId = getOrResetG2Session()
|
|
278
|
+
|
|
279
|
+
// Pass query directly — conciseness instruction is in the system prompt now
|
|
280
|
+
try {
|
|
281
|
+
const returnedSid = await callModelStreaming(query, currentSessionId, {
|
|
282
|
+
onChunk: (text) => {
|
|
283
|
+
if (!done) {
|
|
284
|
+
if (!firstChunkLogged) {
|
|
285
|
+
firstChunkLogged = true
|
|
286
|
+
actualTtfbMs = Date.now() - requestReceivedAt
|
|
287
|
+
console.log(`[g2] TTFB: ${actualTtfbMs}ms (${resolvedModel}, session: ${currentSessionId ? 'resumed' : 'new'})`)
|
|
288
|
+
}
|
|
289
|
+
const chunk = {
|
|
290
|
+
id: completionId,
|
|
291
|
+
object: 'chat.completion.chunk',
|
|
292
|
+
created: timestamp,
|
|
293
|
+
model: responseModel,
|
|
294
|
+
choices: [{ index: 0, delta: { content: text }, finish_reason: null }],
|
|
295
|
+
}
|
|
296
|
+
res.write(`data: ${JSON.stringify(chunk)}\n\n`)
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
onDone: (fullText) => {
|
|
300
|
+
if (!done) {
|
|
301
|
+
done = true
|
|
302
|
+
resolveInflight!(fullText || '')
|
|
303
|
+
inflightQueries.delete(dedupKey)
|
|
304
|
+
logLatency({
|
|
305
|
+
timestamp: new Date().toISOString(),
|
|
306
|
+
query: query.slice(0, 50),
|
|
307
|
+
ttfb_ms: actualTtfbMs,
|
|
308
|
+
total_ms: Date.now() - requestReceivedAt,
|
|
309
|
+
model: resolvedModel,
|
|
310
|
+
resumed: !!currentSessionId,
|
|
311
|
+
contextInjected: /\b(schedule|calendar|meeting|task|tasks|today|tomorrow|next meeting|who do i meet|what's next)\b/i.test(query),
|
|
312
|
+
cacheHit: false,
|
|
313
|
+
stream_requested: true,
|
|
314
|
+
})
|
|
315
|
+
// Final chunk with finish_reason
|
|
316
|
+
const finalChunk = {
|
|
317
|
+
id: completionId,
|
|
318
|
+
object: 'chat.completion.chunk',
|
|
319
|
+
created: timestamp,
|
|
320
|
+
model: responseModel,
|
|
321
|
+
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
|
322
|
+
}
|
|
323
|
+
res.write(`data: ${JSON.stringify(finalChunk)}\n\n`)
|
|
324
|
+
res.write('data: [DONE]\n\n')
|
|
325
|
+
res.end()
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
onError: (error) => {
|
|
329
|
+
if (!done) {
|
|
330
|
+
done = true
|
|
331
|
+
rejectInflight!(new Error(error))
|
|
332
|
+
inflightQueries.delete(dedupKey)
|
|
333
|
+
const errChunk = {
|
|
334
|
+
id: completionId,
|
|
335
|
+
object: 'chat.completion.chunk',
|
|
336
|
+
created: timestamp,
|
|
337
|
+
model: responseModel,
|
|
338
|
+
choices: [{ index: 0, delta: { content: `Error: ${error}` }, finish_reason: 'stop' }],
|
|
339
|
+
}
|
|
340
|
+
res.write(`data: ${JSON.stringify(errChunk)}\n\n`)
|
|
341
|
+
res.write('data: [DONE]\n\n')
|
|
342
|
+
res.end()
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
onToolStatus: (status) => {
|
|
346
|
+
if (!done) {
|
|
347
|
+
// SSE comment — invisible to JSON parsers but keeps connection alive
|
|
348
|
+
res.write(`: ${status}\n\n`)
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
onStart: () => {},
|
|
352
|
+
}, resolvedModel, undefined, undefined, undefined, { lightweight: true })
|
|
353
|
+
// Persist session ID for multi-turn context on subsequent G2 queries
|
|
354
|
+
g2SessionId = returnedSid
|
|
355
|
+
} catch (err: any) {
|
|
356
|
+
if (!done) {
|
|
357
|
+
done = true
|
|
358
|
+
rejectInflight!(err)
|
|
359
|
+
inflightQueries.delete(dedupKey)
|
|
360
|
+
res.write(`data: ${JSON.stringify({ error: { message: err.message } })}\n\n`)
|
|
361
|
+
res.write('data: [DONE]\n\n')
|
|
362
|
+
res.end()
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
req.on('close', () => { done = true })
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Non-streaming response ──
|
|
371
|
+
// Check for session reset command
|
|
372
|
+
if (shouldResetSession(query)) {
|
|
373
|
+
g2SessionId = undefined
|
|
374
|
+
g2SessionDate = new Date().toISOString().slice(0, 10)
|
|
375
|
+
console.log(`[g2] Manual session reset (non-stream)`)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const currentSessionIdNS = getOrResetG2Session()
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
const fullText = await new Promise<string>((resolve, reject) => {
|
|
382
|
+
let result = ''
|
|
383
|
+
let nsFirstChunkMs = -1
|
|
384
|
+
callModelStreaming(query, currentSessionIdNS, {
|
|
385
|
+
onChunk: (text) => {
|
|
386
|
+
if (nsFirstChunkMs < 0) nsFirstChunkMs = Date.now() - requestReceivedAt
|
|
387
|
+
result += text
|
|
388
|
+
},
|
|
389
|
+
onDone: (fullText) => {
|
|
390
|
+
const text = fullText || result
|
|
391
|
+
resolveInflight!(text)
|
|
392
|
+
inflightQueries.delete(dedupKey)
|
|
393
|
+
logLatency({
|
|
394
|
+
timestamp: new Date().toISOString(),
|
|
395
|
+
query: query.slice(0, 50),
|
|
396
|
+
ttfb_ms: nsFirstChunkMs,
|
|
397
|
+
total_ms: Date.now() - requestReceivedAt,
|
|
398
|
+
model: resolvedModel,
|
|
399
|
+
resumed: !!currentSessionIdNS,
|
|
400
|
+
contextInjected: /\b(schedule|calendar|meeting|task|tasks|today|tomorrow)\b/i.test(query),
|
|
401
|
+
cacheHit: false,
|
|
402
|
+
stream_requested: false,
|
|
403
|
+
})
|
|
404
|
+
resolve(text)
|
|
405
|
+
},
|
|
406
|
+
onError: (error) => {
|
|
407
|
+
rejectInflight!(new Error(error))
|
|
408
|
+
inflightQueries.delete(dedupKey)
|
|
409
|
+
reject(new Error(error))
|
|
410
|
+
},
|
|
411
|
+
onToolStatus: () => {},
|
|
412
|
+
onStart: () => {},
|
|
413
|
+
}, resolvedModel, undefined, undefined, undefined, { lightweight: true }).then(sid => { g2SessionId = sid })
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
res.json({
|
|
417
|
+
id: completionId,
|
|
418
|
+
object: 'chat.completion',
|
|
419
|
+
created: timestamp,
|
|
420
|
+
model: responseModel,
|
|
421
|
+
choices: [{
|
|
422
|
+
index: 0,
|
|
423
|
+
message: { role: 'assistant', content: fullText },
|
|
424
|
+
finish_reason: 'stop',
|
|
425
|
+
}],
|
|
426
|
+
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
|
427
|
+
})
|
|
428
|
+
} catch (err: any) {
|
|
429
|
+
res.status(500).json({
|
|
430
|
+
error: { message: err.message, type: 'server_error' },
|
|
431
|
+
})
|
|
432
|
+
}
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
// GET /v1/models — required by some clients for model discovery
|
|
436
|
+
openaiCompatRouter.get('/v1/models', (_req, res) => {
|
|
437
|
+
res.json({
|
|
438
|
+
object: 'list',
|
|
439
|
+
data: [
|
|
440
|
+
{ id: 'cos-opus', object: 'model', created: 1709251200, owned_by: 'cos' },
|
|
441
|
+
{ id: 'cos-sonnet', object: 'model', created: 1709251200, owned_by: 'cos' },
|
|
442
|
+
{ id: 'cos-haiku', object: 'model', created: 1709251200, owned_by: 'cos' },
|
|
443
|
+
{ id: 'cos-codex-high', object: 'model', created: 1709251200, owned_by: 'cos' },
|
|
444
|
+
],
|
|
445
|
+
})
|
|
446
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// OpenAI key management endpoints — phone-side Settings panel writes the key
|
|
2
|
+
// here so users never have to edit a .env file or rerun the wizard.
|
|
3
|
+
//
|
|
4
|
+
// Wire-protocol contract: the key is push-only. The phone POSTs it to /set;
|
|
5
|
+
// the server validates against OpenAI, persists to server/data/openai-key.json,
|
|
6
|
+
// and from then on only ever returns metadata (hasKey/source/savedAt/...). The
|
|
7
|
+
// raw key value is never echoed back to any client. Same intent as the
|
|
8
|
+
// existing /api/tts/* contract that the OPENAI_API_KEY must never reach the
|
|
9
|
+
// client.
|
|
10
|
+
//
|
|
11
|
+
// Resolution priority is enforced upstream by openai-key.ts:
|
|
12
|
+
// env > server/data/openai-key.json > COS scripts .env regex
|
|
13
|
+
// So saving via Settings is a no-op when an env-level OPENAI_API_KEY exists.
|
|
14
|
+
// /status reflects that honestly so the UI can render "Active (env)" instead
|
|
15
|
+
// of pretending the saved value is in use.
|
|
16
|
+
|
|
17
|
+
import { Router } from 'express'
|
|
18
|
+
import { existsSync, mkdirSync, unlinkSync } from 'node:fs'
|
|
19
|
+
import { dirname } from 'node:path'
|
|
20
|
+
import { errMsg } from '../lib/utils.js'
|
|
21
|
+
import { atomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
22
|
+
import { KEY_FILE_PATH, clearCachedKey, getKeyStatus } from '../lib/openai-key.js'
|
|
23
|
+
|
|
24
|
+
export const openaiKeyRouter = Router()
|
|
25
|
+
|
|
26
|
+
/** Validate a candidate key by listing models. The /v1/models endpoint is
|
|
27
|
+
* cheap (returns ~50 model IDs in JSON), supported by every OpenAI-compatible
|
|
28
|
+
* proxy, and returns 401 on a bad key — so a successful 200 confirms the key
|
|
29
|
+
* works for our usage without billing anything. */
|
|
30
|
+
async function validateKey(key: string): Promise<{ ok: true } | { ok: false; status: number; reason: string }> {
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch('https://api.openai.com/v1/models', {
|
|
33
|
+
method: 'GET',
|
|
34
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
35
|
+
signal: AbortSignal.timeout(8_000),
|
|
36
|
+
})
|
|
37
|
+
if (res.ok) return { ok: true }
|
|
38
|
+
const body = await res.text().catch(() => '')
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
status: res.status,
|
|
42
|
+
reason: body.slice(0, 200) || `OpenAI returned ${res.status}`,
|
|
43
|
+
}
|
|
44
|
+
} catch (err) {
|
|
45
|
+
return { ok: false, status: 0, reason: errMsg(err) }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// POST /api/openai-key/set — body { key }. Validates against OpenAI then
|
|
50
|
+
// writes server/data/openai-key.json. Returns metadata only.
|
|
51
|
+
openaiKeyRouter.post('/openai-key/set', async (req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
const raw = req.body?.key
|
|
54
|
+
if (typeof raw !== 'string') {
|
|
55
|
+
return res.status(400).json({ error: 'key is required (string)' })
|
|
56
|
+
}
|
|
57
|
+
const key = raw.trim()
|
|
58
|
+
if (!key) {
|
|
59
|
+
return res.status(400).json({ error: 'key is empty after trim' })
|
|
60
|
+
}
|
|
61
|
+
// Light shape check — sk- prefix is the historical OpenAI convention but
|
|
62
|
+
// proxies (Azure, third-party gateways) use other prefixes too. Length is
|
|
63
|
+
// the safer guard. We rely on the live /v1/models call to reject bad keys.
|
|
64
|
+
if (key.length < 16 || key.length > 512) {
|
|
65
|
+
return res.status(400).json({ error: 'key length looks wrong (expected 16-512 chars)' })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const validation = await validateKey(key)
|
|
69
|
+
if (!validation.ok) {
|
|
70
|
+
return res.status(400).json({
|
|
71
|
+
error: `validation failed: ${validation.reason}`,
|
|
72
|
+
upstreamStatus: validation.status,
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const now = new Date().toISOString()
|
|
77
|
+
const payload = JSON.stringify({ key, savedAt: now, validatedAt: now }, null, 2)
|
|
78
|
+
|
|
79
|
+
// Ensure parent dir exists (server/data/ is gitignored but may not exist
|
|
80
|
+
// on a fresh checkout that's never run a budget write).
|
|
81
|
+
const parent = dirname(KEY_FILE_PATH)
|
|
82
|
+
if (!existsSync(parent)) mkdirSync(parent, { recursive: true })
|
|
83
|
+
|
|
84
|
+
atomicWriteFileSync(KEY_FILE_PATH, payload)
|
|
85
|
+
clearCachedKey()
|
|
86
|
+
|
|
87
|
+
const status = getKeyStatus()
|
|
88
|
+
return res.json({
|
|
89
|
+
ok: true,
|
|
90
|
+
validatedAt: now,
|
|
91
|
+
// Echo back the resolved source — if env is set, source will still be
|
|
92
|
+
// 'env' here (env wins over the file we just wrote). The client uses
|
|
93
|
+
// this to surface "Saved (but env override is active)" when relevant.
|
|
94
|
+
activeSource: status.source,
|
|
95
|
+
})
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return res.status(500).json({ error: errMsg(err) })
|
|
98
|
+
}
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// GET /api/openai-key/status — { hasKey, source, savedAt?, validatedAt? }.
|
|
102
|
+
// Never returns the key value.
|
|
103
|
+
openaiKeyRouter.get('/openai-key/status', (_req, res) => {
|
|
104
|
+
try {
|
|
105
|
+
return res.json(getKeyStatus())
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return res.status(500).json({ error: errMsg(err) })
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// DELETE /api/openai-key — removes server/data/openai-key.json. The env or
|
|
112
|
+
// scripts-env source (if set) takes over on the next resolve.
|
|
113
|
+
openaiKeyRouter.delete('/openai-key', (_req, res) => {
|
|
114
|
+
try {
|
|
115
|
+
if (existsSync(KEY_FILE_PATH)) unlinkSync(KEY_FILE_PATH)
|
|
116
|
+
clearCachedKey()
|
|
117
|
+
return res.json({ ok: true, ...getKeyStatus() })
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return res.status(500).json({ error: errMsg(err) })
|
|
120
|
+
}
|
|
121
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// POST /api/query — streaming SSE endpoint for Claude queries
|
|
2
|
+
// Returns text/event-stream with chunk, done, and error events
|
|
3
|
+
|
|
4
|
+
import { Router } from 'express'
|
|
5
|
+
import { callModelStreaming } from '../lib/model-router.js'
|
|
6
|
+
import { emitDisplay } from '../lib/display-bus.js'
|
|
7
|
+
import { errMsg } from '../lib/utils.js'
|
|
8
|
+
import { normalizeModelPreference } from '../../shared/model-preference.js'
|
|
9
|
+
|
|
10
|
+
const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
11
|
+
WebSearch: 'Searching web...',
|
|
12
|
+
WebFetch: 'Reading page...',
|
|
13
|
+
Read: 'Analyzing photo...',
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const queryRouter = Router()
|
|
17
|
+
|
|
18
|
+
queryRouter.post('/query', async (req, res) => {
|
|
19
|
+
const { query, sessionId, model, image, images, reference, globalMsgNum } = req.body
|
|
20
|
+
|
|
21
|
+
// Normalize: accept `images` array or legacy `image` string
|
|
22
|
+
let validImages: string[] | undefined
|
|
23
|
+
if (Array.isArray(images) && images.length > 0) {
|
|
24
|
+
// Filter to valid non-empty strings, cap at 5
|
|
25
|
+
validImages = images.filter((img: unknown) => typeof img === 'string' && img.length > 0).slice(0, 5)
|
|
26
|
+
if (validImages.length === 0) validImages = undefined
|
|
27
|
+
} else if (typeof image === 'string' && image.length > 0) {
|
|
28
|
+
// Backward compat: wrap single image as array
|
|
29
|
+
validImages = [image]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const resolvedQuery = typeof query === 'string' ? query : ''
|
|
33
|
+
|
|
34
|
+
// Vision queries can have an empty query (default to "describe what you see")
|
|
35
|
+
if ((!resolvedQuery || typeof resolvedQuery !== 'string') && !validImages) {
|
|
36
|
+
return res.status(400).json({ error: 'query string or image required' })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Validate model if provided
|
|
40
|
+
const validModel = normalizeModelPreference(model)
|
|
41
|
+
|
|
42
|
+
// Validate globalMsgNum if provided
|
|
43
|
+
const validGlobalMsgNum = typeof globalMsgNum === 'number' && globalMsgNum > 0
|
|
44
|
+
? globalMsgNum : undefined
|
|
45
|
+
|
|
46
|
+
// Set up SSE headers
|
|
47
|
+
res.writeHead(200, {
|
|
48
|
+
'Content-Type': 'text/event-stream',
|
|
49
|
+
'Cache-Control': 'no-cache',
|
|
50
|
+
'Connection': 'keep-alive',
|
|
51
|
+
'X-Accel-Buffering': 'no', // Disable nginx buffering if proxied
|
|
52
|
+
'Access-Control-Allow-Origin': '*', // Even Hub WebView loads from file://
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
// Flush headers immediately
|
|
56
|
+
res.flushHeaders()
|
|
57
|
+
res.write(': keepalive\n\n')
|
|
58
|
+
|
|
59
|
+
let done = false
|
|
60
|
+
const abortController = new AbortController()
|
|
61
|
+
res.on('close', () => {
|
|
62
|
+
if (!done) abortController.abort()
|
|
63
|
+
done = true
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const sid = await callModelStreaming(resolvedQuery || '', sessionId, {
|
|
68
|
+
onStart: (model, sid, cliSessionId, metadata) => {
|
|
69
|
+
if (!done) {
|
|
70
|
+
const payload = { model, sessionId: sid, cliSessionId, ...metadata }
|
|
71
|
+
res.write(`event: start\ndata: ${JSON.stringify(payload)}\n\n`)
|
|
72
|
+
emitDisplay({ type: 'start', data: payload })
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
onChunk: (text) => {
|
|
76
|
+
if (!done) {
|
|
77
|
+
res.write(`event: chunk\ndata: ${JSON.stringify({ text })}\n\n`)
|
|
78
|
+
emitDisplay({ type: 'chunk', data: { text } })
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
onToolStatus: (toolName) => {
|
|
82
|
+
if (!done) {
|
|
83
|
+
const message = TOOL_STATUS_MESSAGES[toolName] ?? (/\s|\.{3}$/.test(toolName) ? toolName : `Using ${toolName}...`)
|
|
84
|
+
res.write(`event: tool_status\ndata: ${JSON.stringify({ message })}\n\n`)
|
|
85
|
+
emitDisplay({ type: 'tool_status', data: { message } })
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
onDone: (fullText, model, cliSessionId, metadata) => {
|
|
89
|
+
if (!done) {
|
|
90
|
+
done = true
|
|
91
|
+
const payload = { text: fullText, sessionId: sid, model, cliSessionId, ...metadata }
|
|
92
|
+
res.write(`event: done\ndata: ${JSON.stringify(payload)}\n\n`)
|
|
93
|
+
emitDisplay({ type: 'done', data: payload })
|
|
94
|
+
res.end()
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
onError: (error) => {
|
|
98
|
+
if (!done) {
|
|
99
|
+
done = true
|
|
100
|
+
res.write(`event: error\ndata: ${JSON.stringify({ error })}\n\n`)
|
|
101
|
+
emitDisplay({ type: 'error', data: { error } })
|
|
102
|
+
res.end()
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
}, validModel, validImages,
|
|
106
|
+
// Pass reference if provided (for "recall message N" feature)
|
|
107
|
+
reference && typeof reference === 'object' && reference.query && reference.response
|
|
108
|
+
? { query: String(reference.query), response: String(reference.response) }
|
|
109
|
+
: undefined,
|
|
110
|
+
validGlobalMsgNum,
|
|
111
|
+
{ abortSignal: abortController.signal },
|
|
112
|
+
)
|
|
113
|
+
} catch (err: unknown) {
|
|
114
|
+
if (!done) {
|
|
115
|
+
done = true
|
|
116
|
+
res.write(`event: error\ndata: ${JSON.stringify({ error: errMsg(err) })}\n\n`)
|
|
117
|
+
res.end()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
})
|