@gotcos/glasses-server 6.38.0 → 6.39.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/.env.example +21 -1
- package/CHANGELOG.md +55 -0
- package/README.md +14 -1
- package/package.json +2 -2
- package/server/lib/archive.ts +52 -5
- package/server/lib/attached-provider-adapter.ts +2 -48
- package/server/lib/banned-permission-args.ts +45 -0
- package/server/lib/claude-bridge.ts +2 -1
- package/server/lib/codex-bridge.ts +49 -4
- package/server/lib/codex-engine-sessions.ts +8 -2
- package/server/lib/codex-extra-args.ts +222 -0
- package/server/lib/even-hub-speaker-role.ts +116 -0
- package/server/lib/fork-thread.ts +2 -2
- package/server/lib/health-static-probes.ts +23 -1
- package/server/lib/model-router.ts +14 -0
- package/server/lib/ollama-bridge.ts +274 -0
- package/server/lib/ollama-catalog.ts +161 -0
- package/server/lib/ollama-run-ledger.ts +178 -0
- package/server/lib/query-job-runtime.ts +8 -2
- package/server/lib/query-job-types.ts +2 -1
- package/server/routes/health.ts +25 -0
- package/server/routes/openai-compat.ts +2 -0
- package/server/routes/transcribe-stream.ts +25 -0
- package/server/scripts/repair-archive-duplicates.ts +181 -0
- package/shared/model-preference.ts +25 -1
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Direct Ollama chat — POST /api/chat. No Codex --oss, no tools, text only.
|
|
2
|
+
|
|
3
|
+
import type { CallOptions, StreamCallbacks } from './claude-bridge.js'
|
|
4
|
+
import { buildLightweightSystemPrompt } from './context-builder.js'
|
|
5
|
+
import {
|
|
6
|
+
addExchange,
|
|
7
|
+
formatHistoryForPrompt,
|
|
8
|
+
getHistory,
|
|
9
|
+
getOrCreateSession,
|
|
10
|
+
getSessionRaw,
|
|
11
|
+
isNewSession,
|
|
12
|
+
markSessionNotified,
|
|
13
|
+
reconcileExchangeByJobIdentity,
|
|
14
|
+
type Exchange,
|
|
15
|
+
type PromptReference,
|
|
16
|
+
} from './conversation.js'
|
|
17
|
+
import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
|
|
18
|
+
import {
|
|
19
|
+
getOllamaCatalog,
|
|
20
|
+
isOllamaProviderReady,
|
|
21
|
+
ollamaFetch,
|
|
22
|
+
} from './ollama-catalog.js'
|
|
23
|
+
import {
|
|
24
|
+
classifyOllamaError,
|
|
25
|
+
finishOllamaRun,
|
|
26
|
+
startOllamaRun,
|
|
27
|
+
} from './ollama-run-ledger.js'
|
|
28
|
+
import { notifyExchange, notifySessionStart } from './telegram-notify.js'
|
|
29
|
+
import { OLLAMA_MODEL } from '../../shared/model-preference.js'
|
|
30
|
+
|
|
31
|
+
const INACTIVITY_MS = 60_000
|
|
32
|
+
const WALL_MAX_MS = 180_000
|
|
33
|
+
const HISTORY_LIMIT = 20
|
|
34
|
+
|
|
35
|
+
type OllamaChatMessage = { role: 'system' | 'user' | 'assistant'; content: string }
|
|
36
|
+
|
|
37
|
+
export function parseOllamaChatDelta(line: string): { content: string; done: boolean; error?: string } {
|
|
38
|
+
const trimmed = line.trim()
|
|
39
|
+
if (!trimmed) return { content: '', done: false }
|
|
40
|
+
try {
|
|
41
|
+
const event = JSON.parse(trimmed) as {
|
|
42
|
+
error?: unknown
|
|
43
|
+
done?: unknown
|
|
44
|
+
message?: { content?: unknown }
|
|
45
|
+
}
|
|
46
|
+
if (typeof event.error === 'string' && event.error.trim()) {
|
|
47
|
+
return { content: '', done: true, error: event.error.trim() }
|
|
48
|
+
}
|
|
49
|
+
const content = typeof event.message?.content === 'string' ? event.message.content : ''
|
|
50
|
+
return { content, done: event.done === true }
|
|
51
|
+
} catch {
|
|
52
|
+
return { content: '', done: false }
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function historyToOllamaMessages(
|
|
57
|
+
exchanges: Exchange[],
|
|
58
|
+
contextBreaks: number[],
|
|
59
|
+
limit = HISTORY_LIMIT,
|
|
60
|
+
): OllamaChatMessage[] {
|
|
61
|
+
const lastBreak = contextBreaks.length > 0 ? contextBreaks[contextBreaks.length - 1]! : 0
|
|
62
|
+
const recent = exchanges.filter(ex => ex.timestamp >= lastBreak).slice(-limit)
|
|
63
|
+
const messages: OllamaChatMessage[] = []
|
|
64
|
+
for (const ex of recent) {
|
|
65
|
+
const content = ex.content.trim()
|
|
66
|
+
if (!content) continue
|
|
67
|
+
if (ex.role === 'user') messages.push({ role: 'user', content })
|
|
68
|
+
else messages.push({ role: 'assistant', content })
|
|
69
|
+
}
|
|
70
|
+
return messages
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function safeOllamaUserError(message: string): string {
|
|
74
|
+
const code = classifyOllamaError(message)
|
|
75
|
+
if (code === 'ollama.unavailable') return 'Ollama is not running. Start ollama serve on this Mac.'
|
|
76
|
+
if (code === 'ollama.no_model') return 'Ollama has no pulled models. Run ollama pull, then retry.'
|
|
77
|
+
if (code === 'ollama.text_only') return 'Ollama is text-only here. Remove the photo and retry.'
|
|
78
|
+
if (code === 'ollama.timeout') return 'Ollama timed out. Retry or pick another model.'
|
|
79
|
+
return `Ollama failed (${code}). Retry or check that ollama serve is running.`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function callOllamaStreaming(
|
|
83
|
+
query: string,
|
|
84
|
+
sessionId: string | undefined,
|
|
85
|
+
callbacks: StreamCallbacks,
|
|
86
|
+
images?: ModelImageInput[],
|
|
87
|
+
reference?: PromptReference,
|
|
88
|
+
globalMsgNum?: number,
|
|
89
|
+
options?: CallOptions,
|
|
90
|
+
): Promise<string> {
|
|
91
|
+
const sid = getOrCreateSession(sessionId)
|
|
92
|
+
const imageInputs = images ?? []
|
|
93
|
+
const inboundAttachments = options?.requestAttachments ?? []
|
|
94
|
+
|
|
95
|
+
if (imageInputs.length > 0 || inboundAttachments.length > 0) {
|
|
96
|
+
cleanupModelImageInputs(imageInputs)
|
|
97
|
+
await callbacks.onError(safeOllamaUserError('ollama-bridge: Ollama is text-only in this version.'))
|
|
98
|
+
return sid
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await getOllamaCatalog()
|
|
102
|
+
const catalog = await getOllamaCatalog()
|
|
103
|
+
if (!isOllamaProviderReady() || !catalog.model) {
|
|
104
|
+
await callbacks.onError(safeOllamaUserError(
|
|
105
|
+
catalog.error ? `ollama-bridge: ${catalog.error}` : 'ollama-bridge: Ollama is not ready.',
|
|
106
|
+
))
|
|
107
|
+
return sid
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const history = getHistory(sid)
|
|
111
|
+
const session = getSessionRaw(sid)
|
|
112
|
+
const contextBreaks = session?.contextBreaks ?? []
|
|
113
|
+
const historyPrompt = formatHistoryForPrompt(history, contextBreaks, reference)
|
|
114
|
+
const handoffPrompt = options?.handoffContext?.promptBlock ? `\n\n${options.handoffContext.promptBlock}` : ''
|
|
115
|
+
const systemPrompt = `${buildLightweightSystemPrompt(query, `${historyPrompt}${handoffPrompt}`)}\n\nYou have no tools. Answer from the prompt and conversation only. Plain text.`
|
|
116
|
+
|
|
117
|
+
const startTime = Date.now()
|
|
118
|
+
const run = startOllamaRun({
|
|
119
|
+
turnId: options?.turnId,
|
|
120
|
+
clientJobId: options?.clientJobId,
|
|
121
|
+
cosSessionId: sid,
|
|
122
|
+
ollamaModel: catalog.model,
|
|
123
|
+
origin: catalog.origin,
|
|
124
|
+
query,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
callbacks.onStart?.(OLLAMA_MODEL, sid, undefined, { ollamaRunId: run.runId })
|
|
128
|
+
await callbacks.onProviderProcess?.({
|
|
129
|
+
provider: 'ollama',
|
|
130
|
+
runId: run.runId,
|
|
131
|
+
clientJobId: options?.clientJobId,
|
|
132
|
+
generation: options?.jobGeneration ?? options?.generation,
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const jobGeneration = options?.jobGeneration ?? options?.generation
|
|
136
|
+
const durableIdentity = options?.clientJobId && Number.isSafeInteger(jobGeneration) && jobGeneration! > 0
|
|
137
|
+
? { clientJobId: options.clientJobId, generation: jobGeneration! } : undefined
|
|
138
|
+
if (durableIdentity) {
|
|
139
|
+
reconcileExchangeByJobIdentity(sid, durableIdentity, 'user', query, globalMsgNum, undefined, undefined, OLLAMA_MODEL)
|
|
140
|
+
} else {
|
|
141
|
+
addExchange(sid, 'user', query, globalMsgNum, undefined, durableIdentity, OLLAMA_MODEL)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (isNewSession(sid)) {
|
|
145
|
+
notifySessionStart(sid, query)
|
|
146
|
+
markSessionNotified(sid)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const messages: OllamaChatMessage[] = [
|
|
150
|
+
{ role: 'system', content: systemPrompt },
|
|
151
|
+
...historyToOllamaMessages(history, contextBreaks),
|
|
152
|
+
{ role: 'user', content: query },
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
const abort = new AbortController()
|
|
156
|
+
const onExternalAbort = () => abort.abort()
|
|
157
|
+
options?.abortSignal?.addEventListener('abort', onExternalAbort, { once: true })
|
|
158
|
+
|
|
159
|
+
let inactivityTimer: ReturnType<typeof setTimeout> | undefined
|
|
160
|
+
let wallTimer: ReturnType<typeof setTimeout> | undefined
|
|
161
|
+
const clearTimers = () => {
|
|
162
|
+
if (inactivityTimer) clearTimeout(inactivityTimer)
|
|
163
|
+
if (wallTimer) clearTimeout(wallTimer)
|
|
164
|
+
}
|
|
165
|
+
const bumpInactivity = () => {
|
|
166
|
+
if (inactivityTimer) clearTimeout(inactivityTimer)
|
|
167
|
+
inactivityTimer = setTimeout(() => abort.abort(), INACTIVITY_MS)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let fullText = ''
|
|
171
|
+
let finalized = false
|
|
172
|
+
const finalizeError = async (raw: string) => {
|
|
173
|
+
if (finalized) return
|
|
174
|
+
finalized = true
|
|
175
|
+
clearTimers()
|
|
176
|
+
finishOllamaRun(run.runId, { status: 'failed', startedAtMs: startTime, error: raw })
|
|
177
|
+
await callbacks.onError(safeOllamaUserError(raw))
|
|
178
|
+
}
|
|
179
|
+
const finalizeDone = async () => {
|
|
180
|
+
if (finalized) return
|
|
181
|
+
finalized = true
|
|
182
|
+
clearTimers()
|
|
183
|
+
const text = fullText.trim()
|
|
184
|
+
if (!text) {
|
|
185
|
+
finishOllamaRun(run.runId, { status: 'failed', startedAtMs: startTime, error: 'ollama-bridge: empty response' })
|
|
186
|
+
await callbacks.onError(safeOllamaUserError('ollama-bridge: Ollama completed without a response.'))
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
if (durableIdentity) {
|
|
190
|
+
reconcileExchangeByJobIdentity(sid, durableIdentity, 'assistant', text, globalMsgNum, undefined, undefined, OLLAMA_MODEL)
|
|
191
|
+
} else {
|
|
192
|
+
addExchange(sid, 'assistant', text, globalMsgNum, undefined, durableIdentity, OLLAMA_MODEL)
|
|
193
|
+
}
|
|
194
|
+
finishOllamaRun(run.runId, { status: 'completed', startedAtMs: startTime, output: text })
|
|
195
|
+
notifyExchange(sid, query, text)
|
|
196
|
+
await callbacks.onDone(text, OLLAMA_MODEL, undefined, { ollamaRunId: run.runId })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
bumpInactivity()
|
|
200
|
+
wallTimer = setTimeout(() => abort.abort(), WALL_MAX_MS)
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const response = await ollamaFetch(`${catalog.origin}/api/chat`, {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: { 'Content-Type': 'application/json' },
|
|
206
|
+
body: JSON.stringify({
|
|
207
|
+
model: catalog.model,
|
|
208
|
+
messages,
|
|
209
|
+
stream: true,
|
|
210
|
+
}),
|
|
211
|
+
signal: abort.signal,
|
|
212
|
+
})
|
|
213
|
+
if (!response.ok) {
|
|
214
|
+
const detail = (await response.text().catch(() => '')).trim().slice(0, 240)
|
|
215
|
+
await finalizeError(`ollama-bridge: HTTP ${response.status}${detail ? ` — ${detail}` : ''}`)
|
|
216
|
+
return sid
|
|
217
|
+
}
|
|
218
|
+
if (!response.body) {
|
|
219
|
+
await finalizeError('ollama-bridge: empty stream')
|
|
220
|
+
return sid
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const reader = response.body.getReader()
|
|
224
|
+
const decoder = new TextDecoder()
|
|
225
|
+
let buffer = ''
|
|
226
|
+
while (true) {
|
|
227
|
+
const { done, value } = await reader.read()
|
|
228
|
+
if (done) break
|
|
229
|
+
bumpInactivity()
|
|
230
|
+
buffer += decoder.decode(value, { stream: true })
|
|
231
|
+
const lines = buffer.split('\n')
|
|
232
|
+
buffer = lines.pop() ?? ''
|
|
233
|
+
for (const line of lines) {
|
|
234
|
+
const delta = parseOllamaChatDelta(line)
|
|
235
|
+
if (delta.error) {
|
|
236
|
+
await finalizeError(`ollama-bridge: ${delta.error}`)
|
|
237
|
+
return sid
|
|
238
|
+
}
|
|
239
|
+
if (delta.content) {
|
|
240
|
+
fullText += delta.content
|
|
241
|
+
callbacks.onChunk(delta.content)
|
|
242
|
+
}
|
|
243
|
+
if (delta.done) {
|
|
244
|
+
await finalizeDone()
|
|
245
|
+
return sid
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (buffer.trim()) {
|
|
250
|
+
const delta = parseOllamaChatDelta(buffer)
|
|
251
|
+
if (delta.error) {
|
|
252
|
+
await finalizeError(`ollama-bridge: ${delta.error}`)
|
|
253
|
+
return sid
|
|
254
|
+
}
|
|
255
|
+
if (delta.content) {
|
|
256
|
+
fullText += delta.content
|
|
257
|
+
callbacks.onChunk(delta.content)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
await finalizeDone()
|
|
261
|
+
return sid
|
|
262
|
+
} catch (error: any) {
|
|
263
|
+
const aborted = abort.signal.aborted || options?.abortSignal?.aborted
|
|
264
|
+
await finalizeError(
|
|
265
|
+
aborted
|
|
266
|
+
? 'ollama-bridge: request aborted'
|
|
267
|
+
: `ollama-bridge: ${error?.message ?? 'fetch failed'}`,
|
|
268
|
+
)
|
|
269
|
+
return sid
|
|
270
|
+
} finally {
|
|
271
|
+
clearTimers()
|
|
272
|
+
options?.abortSignal?.removeEventListener('abort', onExternalAbort)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Local Ollama discovery. Hidden picker until GET /api/tags succeeds with a model.
|
|
2
|
+
// Host is loopback-only — COS_OLLAMA_HOST that is not 127.0.0.1 / localhost / ::1
|
|
3
|
+
// is refused (SSRF).
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_OLLAMA_ORIGIN = 'http://127.0.0.1:11434'
|
|
6
|
+
const PROBE_TIMEOUT_MS = 2_000
|
|
7
|
+
const CACHE_TTL_MS = 30_000
|
|
8
|
+
|
|
9
|
+
export interface OllamaCatalog {
|
|
10
|
+
ready: boolean
|
|
11
|
+
origin: string
|
|
12
|
+
model: string
|
|
13
|
+
models: string[]
|
|
14
|
+
refreshedAt: string
|
|
15
|
+
error?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type FetchLike = typeof fetch
|
|
19
|
+
let catalogFetch: FetchLike = globalThis.fetch.bind(globalThis)
|
|
20
|
+
|
|
21
|
+
export function ollamaFetch(...args: Parameters<FetchLike>): ReturnType<FetchLike> {
|
|
22
|
+
return catalogFetch(...args)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function _setOllamaCatalogFetchForTests(fn: FetchLike | null): void {
|
|
26
|
+
catalogFetch = fn ?? globalThis.fetch.bind(globalThis)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function unavailableCatalog(origin: string, error: string): OllamaCatalog {
|
|
30
|
+
return {
|
|
31
|
+
ready: false,
|
|
32
|
+
origin,
|
|
33
|
+
model: '',
|
|
34
|
+
models: [],
|
|
35
|
+
refreshedAt: new Date().toISOString(),
|
|
36
|
+
error,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
41
|
+
const host = hostname.replace(/^\[|\]$/g, '').toLowerCase()
|
|
42
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveOllamaOrigin(
|
|
46
|
+
raw: string | undefined = process.env.COS_OLLAMA_HOST,
|
|
47
|
+
): { ok: true; origin: string } | { ok: false; error: string } {
|
|
48
|
+
const trimmed = (raw ?? '').trim()
|
|
49
|
+
if (!trimmed) return { ok: true, origin: DEFAULT_OLLAMA_ORIGIN }
|
|
50
|
+
try {
|
|
51
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`
|
|
52
|
+
const url = new URL(withScheme)
|
|
53
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
54
|
+
return { ok: false, error: 'COS_OLLAMA_HOST must be http(s) on loopback' }
|
|
55
|
+
}
|
|
56
|
+
if (!isLoopbackHostname(url.hostname)) {
|
|
57
|
+
return { ok: false, error: 'COS_OLLAMA_HOST must be loopback (127.0.0.1, localhost, ::1)' }
|
|
58
|
+
}
|
|
59
|
+
if (url.username || url.password) {
|
|
60
|
+
return { ok: false, error: 'COS_OLLAMA_HOST must not include credentials' }
|
|
61
|
+
}
|
|
62
|
+
return { ok: true, origin: url.origin }
|
|
63
|
+
} catch {
|
|
64
|
+
return { ok: false, error: 'COS_OLLAMA_HOST is not a valid URL' }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function selectOllamaModel(names: string[], preferred?: string): string {
|
|
69
|
+
const cleaned = names.map(name => name.trim()).filter(Boolean)
|
|
70
|
+
if (cleaned.length === 0) return ''
|
|
71
|
+
const pin = (preferred ?? process.env.COS_OLLAMA_MODEL ?? '').trim()
|
|
72
|
+
if (!pin) return cleaned[0] ?? ''
|
|
73
|
+
if (cleaned.includes(pin)) return pin
|
|
74
|
+
const tagged = cleaned.find(name => name.startsWith(`${pin}:`))
|
|
75
|
+
return tagged ?? ''
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseOllamaTagNames(body: unknown): string[] {
|
|
79
|
+
if (!body || typeof body !== 'object') return []
|
|
80
|
+
const models = (body as { models?: unknown }).models
|
|
81
|
+
if (!Array.isArray(models)) return []
|
|
82
|
+
const names: string[] = []
|
|
83
|
+
for (const row of models) {
|
|
84
|
+
if (!row || typeof row !== 'object') continue
|
|
85
|
+
const record = row as { name?: unknown; model?: unknown }
|
|
86
|
+
const name = typeof record.name === 'string' ? record.name
|
|
87
|
+
: typeof record.model === 'string' ? record.model
|
|
88
|
+
: ''
|
|
89
|
+
if (name.trim()) names.push(name.trim())
|
|
90
|
+
}
|
|
91
|
+
return names
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let catalogSnapshot: OllamaCatalog = unavailableCatalog(DEFAULT_OLLAMA_ORIGIN, 'unprobed')
|
|
95
|
+
let refreshPromise: Promise<OllamaCatalog> | null = null
|
|
96
|
+
|
|
97
|
+
export function getOllamaCatalogSnapshot(): OllamaCatalog {
|
|
98
|
+
return catalogSnapshot
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function isOllamaProviderReady(): boolean {
|
|
102
|
+
return catalogSnapshot.ready && catalogSnapshot.model.length > 0
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function _resetOllamaCatalogCache(): void {
|
|
106
|
+
catalogSnapshot = unavailableCatalog(DEFAULT_OLLAMA_ORIGIN, 'unprobed')
|
|
107
|
+
refreshPromise = null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function probeOllamaCatalog(): Promise<OllamaCatalog> {
|
|
111
|
+
const originResult = resolveOllamaOrigin()
|
|
112
|
+
if (!originResult.ok) return unavailableCatalog(DEFAULT_OLLAMA_ORIGIN, originResult.error)
|
|
113
|
+
const origin = originResult.origin
|
|
114
|
+
const controller = new AbortController()
|
|
115
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
|
|
116
|
+
try {
|
|
117
|
+
const response = await catalogFetch(`${origin}/api/tags`, { signal: controller.signal })
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
return unavailableCatalog(origin, `ollama /api/tags HTTP ${response.status}`)
|
|
120
|
+
}
|
|
121
|
+
const body = await response.json() as unknown
|
|
122
|
+
const models = parseOllamaTagNames(body)
|
|
123
|
+
const model = selectOllamaModel(models)
|
|
124
|
+
if (!model) return unavailableCatalog(origin, 'no models pulled')
|
|
125
|
+
return {
|
|
126
|
+
ready: true,
|
|
127
|
+
origin,
|
|
128
|
+
model,
|
|
129
|
+
models,
|
|
130
|
+
refreshedAt: new Date().toISOString(),
|
|
131
|
+
}
|
|
132
|
+
} catch (error: any) {
|
|
133
|
+
const message = error?.name === 'AbortError'
|
|
134
|
+
? 'ollama probe timed out'
|
|
135
|
+
: (error?.message ? String(error.message).slice(0, 160) : 'ollama unreachable')
|
|
136
|
+
return unavailableCatalog(origin, message)
|
|
137
|
+
} finally {
|
|
138
|
+
clearTimeout(timer)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function getOllamaCatalog(forceRefresh = false): Promise<OllamaCatalog> {
|
|
143
|
+
const ageMs = Date.now() - Date.parse(catalogSnapshot.refreshedAt)
|
|
144
|
+
if (
|
|
145
|
+
!forceRefresh
|
|
146
|
+
&& catalogSnapshot.ready
|
|
147
|
+
&& Number.isFinite(ageMs)
|
|
148
|
+
&& ageMs >= 0
|
|
149
|
+
&& ageMs < CACHE_TTL_MS
|
|
150
|
+
) {
|
|
151
|
+
return catalogSnapshot
|
|
152
|
+
}
|
|
153
|
+
if (refreshPromise) return refreshPromise
|
|
154
|
+
refreshPromise = probeOllamaCatalog().then(snapshot => {
|
|
155
|
+
catalogSnapshot = snapshot
|
|
156
|
+
return snapshot
|
|
157
|
+
}).finally(() => {
|
|
158
|
+
refreshPromise = null
|
|
159
|
+
})
|
|
160
|
+
return refreshPromise
|
|
161
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, resolve } from 'node:path'
|
|
4
|
+
import { OLLAMA_MODEL, type OllamaModelPreference } from '../../shared/model-preference.js'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_MAX_RUNS = 100
|
|
7
|
+
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60_000
|
|
8
|
+
const ERROR_PREVIEW_CHARS = 160
|
|
9
|
+
const RUNNING_STALE_MS = 30 * 60_000
|
|
10
|
+
|
|
11
|
+
export type OllamaRunStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'client_disconnected'
|
|
12
|
+
|
|
13
|
+
export interface OllamaRunRecord {
|
|
14
|
+
runId: string
|
|
15
|
+
turnId?: string
|
|
16
|
+
clientJobId?: string
|
|
17
|
+
cosSessionId: string
|
|
18
|
+
status: OllamaRunStatus
|
|
19
|
+
createdAt: string
|
|
20
|
+
updatedAt: string
|
|
21
|
+
model: OllamaModelPreference
|
|
22
|
+
ollamaModel: string
|
|
23
|
+
origin: string
|
|
24
|
+
queryPreview?: string
|
|
25
|
+
outputPreview?: string
|
|
26
|
+
errorCode?: string
|
|
27
|
+
errorPreview?: string
|
|
28
|
+
durationMs?: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface OllamaRunEvent {
|
|
32
|
+
runId: string
|
|
33
|
+
ts: string
|
|
34
|
+
patch: Partial<OllamaRunRecord>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getProcessStartedAtMs(): number {
|
|
38
|
+
return Date.now() - Math.floor(process.uptime() * 1000)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getOllamaLedgerPath(): string {
|
|
42
|
+
return resolve(process.env.COS_OLLAMA_RUN_LEDGER_FILE || resolve(import.meta.dirname, '..', 'data', 'ollama-runs.jsonl'))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getMaxRuns(): number {
|
|
46
|
+
const raw = Number(process.env.COS_OLLAMA_RUN_LEDGER_MAX ?? DEFAULT_MAX_RUNS)
|
|
47
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_RUNS
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getTtlMs(): number {
|
|
51
|
+
const rawDays = Number(process.env.COS_OLLAMA_RUN_LEDGER_TTL_DAYS ?? 7)
|
|
52
|
+
return Number.isFinite(rawDays) && rawDays > 0 ? rawDays * 24 * 60 * 60_000 : DEFAULT_TTL_MS
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function appendEvent(event: OllamaRunEvent): void {
|
|
56
|
+
try {
|
|
57
|
+
const path = getOllamaLedgerPath()
|
|
58
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
59
|
+
appendFileSync(path, JSON.stringify(event) + '\n')
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.warn('[ollama-run-ledger] write skipped:', err)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readEvents(): OllamaRunEvent[] {
|
|
66
|
+
const path = getOllamaLedgerPath()
|
|
67
|
+
if (!existsSync(path)) return []
|
|
68
|
+
try {
|
|
69
|
+
const events: OllamaRunEvent[] = []
|
|
70
|
+
for (const line of readFileSync(path, 'utf-8').split('\n').map(row => row.trim()).filter(Boolean)) {
|
|
71
|
+
try {
|
|
72
|
+
const event = JSON.parse(line) as OllamaRunEvent
|
|
73
|
+
if (typeof event.runId === 'string' && typeof event.ts === 'string' && typeof event.patch === 'object') {
|
|
74
|
+
events.push(event)
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Skip torn JSONL rows.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return events
|
|
81
|
+
} catch {
|
|
82
|
+
return []
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function redactForOllamaLedger(value: string, maxChars = ERROR_PREVIEW_CHARS): string {
|
|
87
|
+
return value.replace(/\s+/g, ' ').trim().slice(0, maxChars)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function classifyOllamaError(message: string): string {
|
|
91
|
+
const text = message.toLowerCase()
|
|
92
|
+
if (/unreachable|econnrefused|fetch failed|enotfound/.test(text)) return 'ollama.unavailable'
|
|
93
|
+
if (/no models|not ready/.test(text)) return 'ollama.no_model'
|
|
94
|
+
if (/text-only|photo|image/.test(text)) return 'ollama.text_only'
|
|
95
|
+
if (/timeout|timed out|aborted/.test(text)) return 'ollama.timeout'
|
|
96
|
+
return 'ollama.error'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function startOllamaRun(input: {
|
|
100
|
+
turnId?: string
|
|
101
|
+
clientJobId?: string
|
|
102
|
+
cosSessionId: string
|
|
103
|
+
ollamaModel: string
|
|
104
|
+
origin: string
|
|
105
|
+
query: string
|
|
106
|
+
}): OllamaRunRecord {
|
|
107
|
+
const now = new Date().toISOString()
|
|
108
|
+
const run: OllamaRunRecord = {
|
|
109
|
+
runId: `ollama-${crypto.randomUUID().slice(0, 8)}`,
|
|
110
|
+
turnId: input.turnId,
|
|
111
|
+
clientJobId: input.clientJobId,
|
|
112
|
+
cosSessionId: input.cosSessionId,
|
|
113
|
+
status: 'running',
|
|
114
|
+
createdAt: now,
|
|
115
|
+
updatedAt: now,
|
|
116
|
+
model: OLLAMA_MODEL,
|
|
117
|
+
ollamaModel: input.ollamaModel,
|
|
118
|
+
origin: input.origin,
|
|
119
|
+
queryPreview: redactForOllamaLedger(input.query),
|
|
120
|
+
}
|
|
121
|
+
appendEvent({ runId: run.runId, ts: now, patch: run })
|
|
122
|
+
return run
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function finishOllamaRun(runId: string, input: {
|
|
126
|
+
status: Exclude<OllamaRunStatus, 'running'>
|
|
127
|
+
startedAtMs: number
|
|
128
|
+
output?: string
|
|
129
|
+
error?: string
|
|
130
|
+
}): OllamaRunRecord | null {
|
|
131
|
+
const patch: Partial<OllamaRunRecord> = {
|
|
132
|
+
status: input.status,
|
|
133
|
+
durationMs: Math.max(0, Date.now() - input.startedAtMs),
|
|
134
|
+
}
|
|
135
|
+
if (input.output) patch.outputPreview = redactForOllamaLedger(input.output)
|
|
136
|
+
if (input.error) {
|
|
137
|
+
patch.errorCode = classifyOllamaError(input.error)
|
|
138
|
+
patch.errorPreview = redactForOllamaLedger(input.error)
|
|
139
|
+
}
|
|
140
|
+
const ts = new Date().toISOString()
|
|
141
|
+
appendEvent({ runId, ts, patch: { ...patch, updatedAt: ts } })
|
|
142
|
+
return getOllamaRun(runId)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function getOllamaRun(runId: string): OllamaRunRecord | null {
|
|
146
|
+
return listOllamaRuns(getMaxRuns()).find(run => run.runId === runId) ?? null
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function listOllamaRuns(limit = 20, sessionId?: string): OllamaRunRecord[] {
|
|
150
|
+
const runs = new Map<string, OllamaRunRecord>()
|
|
151
|
+
const order = new Map<string, number>()
|
|
152
|
+
let eventIndex = 0
|
|
153
|
+
for (const event of readEvents()) {
|
|
154
|
+
eventIndex += 1
|
|
155
|
+
const existing = runs.get(event.runId)
|
|
156
|
+
const next = { ...(existing ?? {}), ...event.patch, runId: event.runId } as OllamaRunRecord
|
|
157
|
+
runs.set(event.runId, next)
|
|
158
|
+
order.set(event.runId, eventIndex)
|
|
159
|
+
}
|
|
160
|
+
const cutoff = Date.now() - getTtlMs()
|
|
161
|
+
return Array.from(runs.values())
|
|
162
|
+
.filter(run => run.createdAt && Date.parse(run.updatedAt || run.createdAt) >= cutoff)
|
|
163
|
+
.filter(run => !sessionId || run.cosSessionId === sessionId)
|
|
164
|
+
.map(run => {
|
|
165
|
+
const updatedMs = Date.parse(run.updatedAt || run.createdAt)
|
|
166
|
+
const predatesCurrentProcess = updatedMs < getProcessStartedAtMs() - 1000
|
|
167
|
+
if (run.status === 'running' && (predatesCurrentProcess || Date.now() - updatedMs > RUNNING_STALE_MS)) {
|
|
168
|
+
return {
|
|
169
|
+
...run,
|
|
170
|
+
status: 'client_disconnected' as const,
|
|
171
|
+
errorCode: run.errorCode ?? 'ollama.timeout',
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return run
|
|
175
|
+
})
|
|
176
|
+
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
|
|
177
|
+
.slice(0, limit)
|
|
178
|
+
}
|
|
@@ -13,6 +13,7 @@ import { QueryJobStore } from './query-job-store.js'
|
|
|
13
13
|
import {
|
|
14
14
|
isCodexModel,
|
|
15
15
|
isCursorModel,
|
|
16
|
+
isOllamaModel,
|
|
16
17
|
normalizeEffortPreference,
|
|
17
18
|
normalizeModelPreference,
|
|
18
19
|
type CursorExecutionMode,
|
|
@@ -101,7 +102,8 @@ export async function preparePublicDurableQueryAdmission(raw: unknown): Promise<
|
|
|
101
102
|
}
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
function providerFor(model: ModelPreference): 'claude' | 'codex' | 'cursor' {
|
|
105
|
+
function providerFor(model: ModelPreference): 'claude' | 'codex' | 'cursor' | 'ollama' {
|
|
106
|
+
if (isOllamaModel(model)) return 'ollama'
|
|
105
107
|
if (isCursorModel(model)) return 'cursor'
|
|
106
108
|
return isCodexModel(model) ? 'codex' : 'claude'
|
|
107
109
|
}
|
|
@@ -183,6 +185,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
183
185
|
codexRunId: metadata?.codexRunId,
|
|
184
186
|
codexThreadId: metadata?.codexThreadId,
|
|
185
187
|
cursorRunId: metadata?.cursorRunId,
|
|
188
|
+
ollamaRunId: metadata?.ollamaRunId,
|
|
186
189
|
} as const
|
|
187
190
|
await callbacks.onStart({ sessionId, ...linkage })
|
|
188
191
|
emitDisplay({ type: 'start', data: {
|
|
@@ -205,7 +208,9 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
205
208
|
? { claudeRunId: metadata.runId }
|
|
206
209
|
: metadata.provider === 'cursor'
|
|
207
210
|
? { cursorRunId: metadata.runId }
|
|
208
|
-
:
|
|
211
|
+
: metadata.provider === 'ollama'
|
|
212
|
+
? { ollamaRunId: metadata.runId }
|
|
213
|
+
: { codexRunId: metadata.runId }),
|
|
209
214
|
}),
|
|
210
215
|
onChunk: text => { callbacks.onChunk(text) },
|
|
211
216
|
onToolStatus: toolName => {
|
|
@@ -239,6 +244,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
239
244
|
codexRunId: metadata?.codexRunId,
|
|
240
245
|
codexThreadId: metadata?.codexThreadId,
|
|
241
246
|
cursorRunId: metadata?.cursorRunId,
|
|
247
|
+
ollamaRunId: metadata?.ollamaRunId,
|
|
242
248
|
} as const
|
|
243
249
|
// Publish compatibility completion only after the durable terminal is
|
|
244
250
|
// fsynced. Display subscribers can disappear without owning this job.
|
|
@@ -75,13 +75,14 @@ export interface QueryJobRequest {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
export interface QueryJobProviderLinkage {
|
|
78
|
-
provider?: 'claude' | 'codex' | 'cursor'
|
|
78
|
+
provider?: 'claude' | 'codex' | 'cursor' | 'ollama'
|
|
79
79
|
resolvedModel?: string
|
|
80
80
|
cliSessionId?: string
|
|
81
81
|
claudeRunId?: string
|
|
82
82
|
codexRunId?: string
|
|
83
83
|
codexThreadId?: string
|
|
84
84
|
cursorRunId?: string
|
|
85
|
+
ollamaRunId?: string
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
/** Path/id-free aggregate from output-image finalization. Values are bounded
|