@gotcos/glasses-server 6.38.1 → 6.39.1
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 +48 -0
- package/README.md +14 -1
- package/package.json +2 -2
- 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-store.ts +8 -3
- 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/shared/model-preference.ts +25 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Even Hub 0.0.14 wearer-vs-other histogram, carried on a meeting chunk.
|
|
2
|
+
// Identity is a suggestion. This module parses and logs. It does not name
|
|
3
|
+
// people and does not change identifyChunkSpeaker.
|
|
4
|
+
|
|
5
|
+
export type EvenSpeakerRole = 'self' | 'other' | 'unknown'
|
|
6
|
+
export type EvenSpeakerRoleMajority = EvenSpeakerRole | 'tie'
|
|
7
|
+
|
|
8
|
+
export interface EvenSpeakerRoleHistogram {
|
|
9
|
+
schema: 1
|
|
10
|
+
frames: number
|
|
11
|
+
self: number
|
|
12
|
+
other: number
|
|
13
|
+
unknown: number
|
|
14
|
+
majority: EvenSpeakerRoleMajority
|
|
15
|
+
directionPresent: number
|
|
16
|
+
directionLast: number | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type EvenSpeakerRoleMode = 'off' | 'log' | 'apply'
|
|
20
|
+
|
|
21
|
+
export function evenSpeakerRoleMode(): EvenSpeakerRoleMode {
|
|
22
|
+
const raw = (process.env.COS_EVEN_SPEAKER_ROLE ?? 'log').trim().toLowerCase()
|
|
23
|
+
if (raw === 'off' || raw === '0' || raw === 'false') return 'off'
|
|
24
|
+
if (raw === 'apply') return 'apply'
|
|
25
|
+
return 'log'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let applyNotImplementedWarned = false
|
|
29
|
+
|
|
30
|
+
/** Gate A is not in this slice. apply must not silently change labels. */
|
|
31
|
+
export function warnEvenSpeakerRoleApplyNotImplemented(): void {
|
|
32
|
+
if (evenSpeakerRoleMode() !== 'apply' || applyNotImplementedWarned) return
|
|
33
|
+
applyNotImplementedWarned = true
|
|
34
|
+
console.warn('[even-role] COS_EVEN_SPEAKER_ROLE=apply is not implemented; logging only')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function majorityOf(self: number, other: number, unknown: number, frames: number): EvenSpeakerRoleMajority {
|
|
38
|
+
if (frames <= 0) return 'unknown'
|
|
39
|
+
if (self > other && self > unknown) return 'self'
|
|
40
|
+
if (other > self && other > unknown) return 'other'
|
|
41
|
+
if (unknown > self && unknown > other) return 'unknown'
|
|
42
|
+
return 'tie'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function asNonNegInt(raw: unknown): number | null {
|
|
46
|
+
const n = typeof raw === 'number' ? raw : typeof raw === 'string' && raw !== '' ? Number(raw) : NaN
|
|
47
|
+
if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) return null
|
|
48
|
+
return n
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Compact query `eh=self,other,unknown,frames,directionPresent,directionLast`. */
|
|
52
|
+
export function parseEvenHubSpeakerRoleQuery(raw: unknown): EvenSpeakerRoleHistogram | undefined {
|
|
53
|
+
if (typeof raw !== 'string' || raw.length === 0) return undefined
|
|
54
|
+
const parts = raw.split(',')
|
|
55
|
+
if (parts.length < 4 || parts.length > 6) return undefined
|
|
56
|
+
const self = asNonNegInt(parts[0])
|
|
57
|
+
const other = asNonNegInt(parts[1])
|
|
58
|
+
const unknown = asNonNegInt(parts[2])
|
|
59
|
+
const frames = asNonNegInt(parts[3])
|
|
60
|
+
if (self == null || other == null || unknown == null || frames == null) return undefined
|
|
61
|
+
if (self + other + unknown !== frames) return undefined
|
|
62
|
+
const directionPresent = parts.length >= 5 ? asNonNegInt(parts[4]) : 0
|
|
63
|
+
if (directionPresent == null) return undefined
|
|
64
|
+
let directionLast: number | null = null
|
|
65
|
+
if (parts.length === 6 && parts[5] !== '') {
|
|
66
|
+
const last = Number(parts[5])
|
|
67
|
+
if (!Number.isFinite(last)) return undefined
|
|
68
|
+
directionLast = last
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
schema: 1,
|
|
72
|
+
frames,
|
|
73
|
+
self,
|
|
74
|
+
other,
|
|
75
|
+
unknown,
|
|
76
|
+
majority: majorityOf(self, other, unknown, frames),
|
|
77
|
+
directionPresent,
|
|
78
|
+
directionLast,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function parseEvenHubSpeakerRoleBody(raw: unknown): EvenSpeakerRoleHistogram | undefined {
|
|
83
|
+
if (!raw || typeof raw !== 'object') return undefined
|
|
84
|
+
const o = raw as Record<string, unknown>
|
|
85
|
+
const self = asNonNegInt(o.self)
|
|
86
|
+
const other = asNonNegInt(o.other)
|
|
87
|
+
const unknown = asNonNegInt(o.unknown)
|
|
88
|
+
const frames = asNonNegInt(o.frames)
|
|
89
|
+
if (self == null || other == null || unknown == null || frames == null) return undefined
|
|
90
|
+
if (self + other + unknown !== frames) return undefined
|
|
91
|
+
const directionPresent = o.directionPresent == null ? 0 : asNonNegInt(o.directionPresent)
|
|
92
|
+
if (directionPresent == null) return undefined
|
|
93
|
+
const directionLast = o.directionLast == null || o.directionLast === ''
|
|
94
|
+
? null
|
|
95
|
+
: (typeof o.directionLast === 'number' && Number.isFinite(o.directionLast) ? o.directionLast : null)
|
|
96
|
+
return {
|
|
97
|
+
schema: 1,
|
|
98
|
+
frames,
|
|
99
|
+
self,
|
|
100
|
+
other,
|
|
101
|
+
unknown,
|
|
102
|
+
majority: majorityOf(self, other, unknown, frames),
|
|
103
|
+
directionPresent,
|
|
104
|
+
directionLast,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function formatEvenRoleAgreement(opts: {
|
|
109
|
+
chunkIndex: number
|
|
110
|
+
even: EvenSpeakerRoleHistogram
|
|
111
|
+
amp: string
|
|
112
|
+
emb: string
|
|
113
|
+
similarity: number
|
|
114
|
+
}): string {
|
|
115
|
+
return `[even-role] chunk=${opts.chunkIndex} even=${opts.even.majority} amp=${opts.amp} emb=${opts.emb} sim=${opts.similarity.toFixed(2)} frames=${opts.even.frames}`
|
|
116
|
+
}
|
|
@@ -78,7 +78,6 @@ import {
|
|
|
78
78
|
buildAttachedEnv,
|
|
79
79
|
classifyStderr,
|
|
80
80
|
extractNativeIdsFromLine,
|
|
81
|
-
findBannedPermissionArg,
|
|
82
81
|
isAttachedPermissionPolicy,
|
|
83
82
|
resolveProviderBinary,
|
|
84
83
|
type AttachedChildProcess,
|
|
@@ -86,6 +85,7 @@ import {
|
|
|
86
85
|
type AttachedStderrClass,
|
|
87
86
|
type BinaryResolution,
|
|
88
87
|
} from './attached-provider-adapter.js'
|
|
88
|
+
import { findBannedPermissionArg } from './banned-permission-args.js'
|
|
89
89
|
|
|
90
90
|
/**
|
|
91
91
|
* Providers that can be forked. A strict subset of the attached set.
|
|
@@ -261,7 +261,7 @@ export function buildCodexForkArgs(nativeThreadId: string, cwd: string): string[
|
|
|
261
261
|
/**
|
|
262
262
|
* Build the argv, and REFUSE to hand back one carrying a banned permission flag.
|
|
263
263
|
*
|
|
264
|
-
* The predicate is IMPORTED from
|
|
264
|
+
* The predicate is IMPORTED from banned-permission-args, not re-listed here. Two copies of a
|
|
265
265
|
* ban list in two modules is precisely the drift `native-thread-id.ts` was created
|
|
266
266
|
* to end: the occupancy detector and the binding store each had their own idea of
|
|
267
267
|
* what a thread id was, and a truncated id walked through the gap. A fork spawns a
|
|
@@ -6,6 +6,11 @@ import {
|
|
|
6
6
|
isCursorProviderReady,
|
|
7
7
|
resolveAgentBinary,
|
|
8
8
|
} from './cursor-model-catalog.js'
|
|
9
|
+
import {
|
|
10
|
+
getOllamaCatalog,
|
|
11
|
+
getOllamaCatalogSnapshot,
|
|
12
|
+
isOllamaProviderReady,
|
|
13
|
+
} from './ollama-catalog.js'
|
|
9
14
|
|
|
10
15
|
const DEFAULT_CACHE_TTL_MS = 30_000
|
|
11
16
|
const PROBE_TIMEOUT_MS = 5_000
|
|
@@ -15,9 +20,11 @@ export interface HealthStaticProbeSnapshot {
|
|
|
15
20
|
claude: string
|
|
16
21
|
codex: string
|
|
17
22
|
cursor: string
|
|
23
|
+
ollama: string
|
|
18
24
|
claudeAvailable: boolean
|
|
19
25
|
codexAvailable: boolean
|
|
20
26
|
cursorAvailable: boolean
|
|
27
|
+
ollamaAvailable: boolean
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
interface CachedProbe<T> {
|
|
@@ -153,21 +160,36 @@ async function probeCursor(): Promise<{ value: string; available: boolean }> {
|
|
|
153
160
|
}
|
|
154
161
|
}
|
|
155
162
|
|
|
163
|
+
async function probeOllama(): Promise<{ value: string; available: boolean }> {
|
|
164
|
+
try {
|
|
165
|
+
const catalog = await getOllamaCatalog()
|
|
166
|
+
const available = isOllamaProviderReady()
|
|
167
|
+
if (available) return { value: catalog.model || 'available', available: true }
|
|
168
|
+
return { value: catalog.error || 'unavailable', available: false }
|
|
169
|
+
} catch {
|
|
170
|
+
const snapshot = getOllamaCatalogSnapshot()
|
|
171
|
+
return { value: snapshot.error || 'error', available: false }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
156
175
|
async function loadStaticHealthProbes(): Promise<HealthStaticProbeSnapshot> {
|
|
157
|
-
const [python, claude, codex, cursor] = await Promise.all([
|
|
176
|
+
const [python, claude, codex, cursor, ollama] = await Promise.all([
|
|
158
177
|
probePython(),
|
|
159
178
|
probeClaude(),
|
|
160
179
|
probeCodex(),
|
|
161
180
|
probeCursor(),
|
|
181
|
+
probeOllama(),
|
|
162
182
|
])
|
|
163
183
|
return {
|
|
164
184
|
python,
|
|
165
185
|
claude: claude.value,
|
|
166
186
|
codex: codex.value,
|
|
167
187
|
cursor: cursor.value,
|
|
188
|
+
ollama: ollama.value,
|
|
168
189
|
claudeAvailable: claude.available,
|
|
169
190
|
codexAvailable: codex.available,
|
|
170
191
|
cursorAvailable: cursor.available,
|
|
192
|
+
ollamaAvailable: ollama.available,
|
|
171
193
|
}
|
|
172
194
|
}
|
|
173
195
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { callClaudeStreaming, type CallOptions, type StreamCallbacks } from './claude-bridge.js'
|
|
2
2
|
import { callCodexStreaming } from './codex-bridge.js'
|
|
3
3
|
import { callCursorStreaming } from './cursor-bridge.js'
|
|
4
|
+
import { callOllamaStreaming } from './ollama-bridge.js'
|
|
4
5
|
import {
|
|
5
6
|
getOrCreateSession,
|
|
6
7
|
getSessionModel,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
isCodexModel,
|
|
14
15
|
isClaudeModel,
|
|
15
16
|
isCursorModel,
|
|
17
|
+
isOllamaModel,
|
|
16
18
|
normalizeModelPreference,
|
|
17
19
|
} from '../../shared/model-preference.js'
|
|
18
20
|
import {
|
|
@@ -20,6 +22,10 @@ import {
|
|
|
20
22
|
isCursorProviderReady,
|
|
21
23
|
resolveCursorModelOption,
|
|
22
24
|
} from './cursor-model-catalog.js'
|
|
25
|
+
import {
|
|
26
|
+
getOllamaCatalog,
|
|
27
|
+
isOllamaProviderReady,
|
|
28
|
+
} from './ollama-catalog.js'
|
|
23
29
|
import type { ModelImageInput } from './model-image-input.js'
|
|
24
30
|
|
|
25
31
|
// Bridges return as soon as their subprocess is spawned, while completion is
|
|
@@ -112,6 +118,14 @@ export async function callModelStreaming(
|
|
|
112
118
|
}
|
|
113
119
|
return await callCursorStreaming(query, sid, lockedCallbacks, resolvedModel, images, reference, globalMsgNum, options)
|
|
114
120
|
}
|
|
121
|
+
if (isOllamaModel(resolvedModel)) {
|
|
122
|
+
await getOllamaCatalog()
|
|
123
|
+
if (!isOllamaProviderReady()) {
|
|
124
|
+
await lockedCallbacks.onError('ollama-bridge: Ollama is not running. Start ollama serve on this Mac.')
|
|
125
|
+
return sid
|
|
126
|
+
}
|
|
127
|
+
return await callOllamaStreaming(query, sid, lockedCallbacks, images, reference, globalMsgNum, options)
|
|
128
|
+
}
|
|
115
129
|
if (isCodexModel(resolvedModel)) {
|
|
116
130
|
return await callCodexStreaming(query, sid, lockedCallbacks, resolvedModel, images, reference, globalMsgNum, options)
|
|
117
131
|
}
|
|
@@ -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
|
+
}
|