ethagent 2.3.0 → 2.4.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/README.md +11 -0
- package/package.json +1 -1
- package/src/app/FirstRun.tsx +2 -0
- package/src/chat/ChatBottomPane.tsx +9 -0
- package/src/chat/ChatScreen.tsx +10 -4
- package/src/chat/chatSessionState.ts +4 -1
- package/src/chat/chatTurnOrchestrator.ts +6 -2
- package/src/chat/input/ChatInput.tsx +25 -2
- package/src/chat/input/imageRefs.ts +30 -0
- package/src/chat/views/ResumeView.tsx +16 -7
- package/src/models/ModelPicker.tsx +138 -6
- package/src/models/huggingface.ts +180 -2
- package/src/models/llamacpp.ts +110 -15
- package/src/models/llamacppPreflight.ts +30 -11
- package/src/models/modelPickerOptions.ts +14 -1
- package/src/providers/anthropic.ts +36 -5
- package/src/providers/contracts.ts +9 -1
- package/src/providers/gemini.ts +29 -3
- package/src/providers/openai-chat.ts +81 -2
- package/src/providers/openai-responses-format.ts +29 -8
- package/src/providers/openai-responses.ts +22 -7
- package/src/providers/registry.ts +1 -0
- package/src/storage/config.ts +1 -0
- package/src/storage/sessions.ts +14 -2
- package/src/ui/Spinner.tsx +14 -2
- package/src/ui/theme.ts +2 -0
- package/src/utils/images.ts +140 -0
- package/src/utils/messages.ts +2 -0
|
@@ -54,6 +54,12 @@ export type HfSafetyReview = {
|
|
|
54
54
|
reasons: string[]
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
export type HfMmprojCandidate = {
|
|
58
|
+
filename: string
|
|
59
|
+
sizeBytes: number
|
|
60
|
+
localPath: string
|
|
61
|
+
}
|
|
62
|
+
|
|
57
63
|
export type HfDownloadPlan = {
|
|
58
64
|
repo: HuggingFaceRepoInfo
|
|
59
65
|
repoId: string
|
|
@@ -64,6 +70,8 @@ export type HfDownloadPlan = {
|
|
|
64
70
|
localPath: string
|
|
65
71
|
displayName: string
|
|
66
72
|
review: HfSafetyReview
|
|
73
|
+
mmprojCandidate?: HfMmprojCandidate
|
|
74
|
+
includeMmproj?: boolean
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
export type LocalHfModel = {
|
|
@@ -90,6 +98,9 @@ export type LocalHfModel = {
|
|
|
90
98
|
installedAt: string
|
|
91
99
|
status: LocalHfStatus
|
|
92
100
|
sha256?: string
|
|
101
|
+
mmprojPath?: string
|
|
102
|
+
mmprojAvailable?: boolean
|
|
103
|
+
mmprojSizeBytes?: number
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
export type HfDownloadProgress = {
|
|
@@ -291,6 +302,14 @@ export function ggufFiles(repo: HuggingFaceRepoInfo): HuggingFaceSibling[] {
|
|
|
291
302
|
.sort((a, b) => a.filename.localeCompare(b.filename))
|
|
292
303
|
}
|
|
293
304
|
|
|
305
|
+
export function isMmprojFilename(filename: string): boolean {
|
|
306
|
+
return filename.toLowerCase().startsWith('mmproj-') && filename.toLowerCase().endsWith('.gguf')
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function findMmprojSibling(repo: HuggingFaceRepoInfo): HuggingFaceSibling | undefined {
|
|
310
|
+
return repo.siblings.find(file => isMmprojFilename(file.filename))
|
|
311
|
+
}
|
|
312
|
+
|
|
294
313
|
export async function createHfDownloadPlan(
|
|
295
314
|
input: string,
|
|
296
315
|
filename?: string,
|
|
@@ -320,6 +339,14 @@ export async function createHfDownloadPlan(
|
|
|
320
339
|
requestedRevision,
|
|
321
340
|
resolvedRevision,
|
|
322
341
|
})
|
|
342
|
+
const mmprojSibling = findMmprojSibling(repo)
|
|
343
|
+
const mmprojCandidate: HfMmprojCandidate | undefined = mmprojSibling
|
|
344
|
+
? {
|
|
345
|
+
filename: mmprojSibling.filename,
|
|
346
|
+
sizeBytes: mmprojSibling.sizeBytes ?? 0,
|
|
347
|
+
localPath: localPathFor(repo.repoId, resolvedRevision, mmprojSibling.filename),
|
|
348
|
+
}
|
|
349
|
+
: undefined
|
|
323
350
|
return {
|
|
324
351
|
repo,
|
|
325
352
|
repoId: repo.repoId,
|
|
@@ -330,6 +357,7 @@ export async function createHfDownloadPlan(
|
|
|
330
357
|
localPath: localPathFor(repo.repoId, resolvedRevision, selected.filename),
|
|
331
358
|
displayName: displayNameFor(repo.repoId, selected.filename),
|
|
332
359
|
review,
|
|
360
|
+
mmprojCandidate,
|
|
333
361
|
}
|
|
334
362
|
}
|
|
335
363
|
|
|
@@ -432,10 +460,151 @@ export async function* downloadHfModel(
|
|
|
432
460
|
}
|
|
433
461
|
|
|
434
462
|
await fs.rename(partialPath, plan.localPath)
|
|
435
|
-
|
|
463
|
+
|
|
464
|
+
let mmprojPath: string | undefined
|
|
465
|
+
if (plan.includeMmproj && plan.mmprojCandidate) {
|
|
466
|
+
yield* downloadMmprojFile(plan.repoId, plan.resolvedRevision, plan.mmprojCandidate, signal, fetchImpl)
|
|
467
|
+
mmprojPath = plan.mmprojCandidate.localPath
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
await upsertLocalHfModel(modelFromPlan(plan, hash.digest('hex'), 'ready', mmprojPath))
|
|
436
471
|
yield { status: 'success', completed, total: Number.isFinite(total) ? total : completed }
|
|
437
472
|
}
|
|
438
473
|
|
|
474
|
+
async function* downloadMmprojFile(
|
|
475
|
+
repoId: string,
|
|
476
|
+
resolvedRevision: string,
|
|
477
|
+
candidate: HfMmprojCandidate,
|
|
478
|
+
signal: AbortSignal | undefined,
|
|
479
|
+
fetchImpl: FetchImpl,
|
|
480
|
+
): AsyncIterable<HfDownloadProgress> {
|
|
481
|
+
await fs.mkdir(path.dirname(candidate.localPath), { recursive: true })
|
|
482
|
+
const partialPath = `${candidate.localPath}.partial`
|
|
483
|
+
const response = await fetchImpl(resolveUrl(repoId, resolvedRevision, candidate.filename), { signal })
|
|
484
|
+
if (!response.ok || !response.body) {
|
|
485
|
+
throw new Error(response.ok ? 'empty projector download body' : `projector download HTTP ${response.status}`)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const total = Number.parseInt(response.headers.get('content-length') ?? '', 10)
|
|
489
|
+
const handle = await fs.open(partialPath, 'w')
|
|
490
|
+
let completed = 0
|
|
491
|
+
let complete = false
|
|
492
|
+
let lastProgressAt = Date.now()
|
|
493
|
+
let lastProgressBytes = 0
|
|
494
|
+
yield { status: 'downloading-mmproj', completed, total: Number.isFinite(total) ? total : undefined }
|
|
495
|
+
try {
|
|
496
|
+
const reader = response.body.getReader()
|
|
497
|
+
while (true) {
|
|
498
|
+
const { done, value } = await reader.read()
|
|
499
|
+
if (done) break
|
|
500
|
+
if (signal?.aborted) throw new Error('Cancelled')
|
|
501
|
+
const buffer = Buffer.from(value)
|
|
502
|
+
await handle.write(buffer)
|
|
503
|
+
completed += buffer.byteLength
|
|
504
|
+
const now = Date.now()
|
|
505
|
+
if (shouldReportDownloadProgress(completed, lastProgressBytes, now, lastProgressAt)) {
|
|
506
|
+
lastProgressAt = now
|
|
507
|
+
lastProgressBytes = completed
|
|
508
|
+
yield { status: 'downloading-mmproj', completed, total: Number.isFinite(total) ? total : undefined }
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
complete = true
|
|
512
|
+
} finally {
|
|
513
|
+
await handle.close()
|
|
514
|
+
if (!complete) {
|
|
515
|
+
await fs.unlink(partialPath).catch(() => {})
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
await fs.rename(partialPath, candidate.localPath)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export async function backfillMmprojAvailability(
|
|
523
|
+
model: LocalHfModel,
|
|
524
|
+
fetchImpl: FetchImpl = fetch,
|
|
525
|
+
): Promise<LocalHfModel> {
|
|
526
|
+
if (model.mmprojAvailable !== undefined) return model
|
|
527
|
+
try {
|
|
528
|
+
const repo = await fetchHuggingFaceRepoInfo({ repoId: model.repoId }, fetchImpl)
|
|
529
|
+
const sibling = findMmprojSibling(repo)
|
|
530
|
+
const next: LocalHfModel = {
|
|
531
|
+
...model,
|
|
532
|
+
mmprojAvailable: Boolean(sibling),
|
|
533
|
+
mmprojSizeBytes: sibling?.sizeBytes,
|
|
534
|
+
}
|
|
535
|
+
await upsertLocalHfModel(next)
|
|
536
|
+
return next
|
|
537
|
+
} catch {
|
|
538
|
+
return model
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export async function backfillMmprojForModels(
|
|
543
|
+
models: LocalHfModel[],
|
|
544
|
+
fetchImpl: FetchImpl = fetch,
|
|
545
|
+
): Promise<LocalHfModel[]> {
|
|
546
|
+
const repoIdToProbe = new Map<string, Promise<HuggingFaceRepoInfo | null>>()
|
|
547
|
+
for (const model of models) {
|
|
548
|
+
if (model.mmprojAvailable !== undefined) continue
|
|
549
|
+
if (repoIdToProbe.has(model.repoId)) continue
|
|
550
|
+
repoIdToProbe.set(
|
|
551
|
+
model.repoId,
|
|
552
|
+
fetchHuggingFaceRepoInfo({ repoId: model.repoId }, fetchImpl).catch(() => null),
|
|
553
|
+
)
|
|
554
|
+
}
|
|
555
|
+
if (repoIdToProbe.size === 0) return models
|
|
556
|
+
const resolved = new Map<string, HuggingFaceRepoInfo | null>()
|
|
557
|
+
for (const [repoId, promise] of repoIdToProbe) {
|
|
558
|
+
resolved.set(repoId, await promise)
|
|
559
|
+
}
|
|
560
|
+
const out: LocalHfModel[] = []
|
|
561
|
+
for (const model of models) {
|
|
562
|
+
if (model.mmprojAvailable !== undefined) {
|
|
563
|
+
out.push(model)
|
|
564
|
+
continue
|
|
565
|
+
}
|
|
566
|
+
const repo = resolved.get(model.repoId)
|
|
567
|
+
if (!repo) {
|
|
568
|
+
out.push(model)
|
|
569
|
+
continue
|
|
570
|
+
}
|
|
571
|
+
const sibling = findMmprojSibling(repo)
|
|
572
|
+
const next: LocalHfModel = {
|
|
573
|
+
...model,
|
|
574
|
+
mmprojAvailable: Boolean(sibling),
|
|
575
|
+
mmprojSizeBytes: sibling?.sizeBytes,
|
|
576
|
+
}
|
|
577
|
+
await upsertLocalHfModel(next)
|
|
578
|
+
out.push(next)
|
|
579
|
+
}
|
|
580
|
+
return out
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export async function* addMmprojToInstalledModel(
|
|
584
|
+
modelId: string,
|
|
585
|
+
signal?: AbortSignal,
|
|
586
|
+
deps: { fetchImpl?: FetchImpl } = {},
|
|
587
|
+
): AsyncIterable<HfDownloadProgress> {
|
|
588
|
+
const fetchImpl = deps.fetchImpl ?? fetch
|
|
589
|
+
const existing = await findLocalHfModel(modelId)
|
|
590
|
+
if (!existing) throw new Error(`model not installed: ${modelId}`)
|
|
591
|
+
if (existing.mmprojPath) {
|
|
592
|
+
yield { status: 'success', completed: 0 }
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
const repo = await fetchHuggingFaceRepoInfo({ repoId: existing.repoId }, fetchImpl)
|
|
596
|
+
const sibling = findMmprojSibling(repo)
|
|
597
|
+
if (!sibling) throw new Error(`no vision encoder available for ${existing.repoId}`)
|
|
598
|
+
const candidate: HfMmprojCandidate = {
|
|
599
|
+
filename: sibling.filename,
|
|
600
|
+
sizeBytes: sibling.sizeBytes ?? 0,
|
|
601
|
+
localPath: localPathFor(existing.repoId, existing.resolvedRevision, sibling.filename),
|
|
602
|
+
}
|
|
603
|
+
yield* downloadMmprojFile(existing.repoId, existing.resolvedRevision, candidate, signal, fetchImpl)
|
|
604
|
+
await upsertLocalHfModel({ ...existing, mmprojPath: candidate.localPath })
|
|
605
|
+
yield { status: 'success', completed: candidate.sizeBytes }
|
|
606
|
+
}
|
|
607
|
+
|
|
439
608
|
export function shouldReportDownloadProgress(
|
|
440
609
|
completed: number,
|
|
441
610
|
lastCompleted: number,
|
|
@@ -446,7 +615,13 @@ export function shouldReportDownloadProgress(
|
|
|
446
615
|
|| completed - lastCompleted >= DOWNLOAD_PROGRESS_MIN_BYTES
|
|
447
616
|
}
|
|
448
617
|
|
|
449
|
-
export function modelFromPlan(
|
|
618
|
+
export function modelFromPlan(
|
|
619
|
+
plan: HfDownloadPlan,
|
|
620
|
+
sha256: string | undefined,
|
|
621
|
+
status: LocalHfStatus,
|
|
622
|
+
mmprojPath?: string,
|
|
623
|
+
): LocalHfModel {
|
|
624
|
+
const mmprojAvailable = Boolean(plan.mmprojCandidate)
|
|
450
625
|
const now = new Date().toISOString()
|
|
451
626
|
return {
|
|
452
627
|
id: localModelId(plan.repoId, plan.filename),
|
|
@@ -472,6 +647,9 @@ export function modelFromPlan(plan: HfDownloadPlan, sha256: string | undefined,
|
|
|
472
647
|
installedAt: now,
|
|
473
648
|
status,
|
|
474
649
|
sha256,
|
|
650
|
+
mmprojPath,
|
|
651
|
+
mmprojAvailable,
|
|
652
|
+
mmprojSizeBytes: plan.mmprojCandidate?.sizeBytes,
|
|
475
653
|
}
|
|
476
654
|
}
|
|
477
655
|
|
package/src/models/llamacpp.ts
CHANGED
|
@@ -50,6 +50,7 @@ export type LlamaCppStartFailureCode =
|
|
|
50
50
|
| 'spawn-failed'
|
|
51
51
|
| 'runner-exited'
|
|
52
52
|
| 'readiness-timeout'
|
|
53
|
+
| 'untracked-server'
|
|
53
54
|
|
|
54
55
|
export type LlamaCppStartResult =
|
|
55
56
|
| { ok: true; alreadyRunning: boolean }
|
|
@@ -362,25 +363,45 @@ export async function startLlamaCppServer(args: {
|
|
|
362
363
|
modelAlias: string
|
|
363
364
|
host?: string
|
|
364
365
|
ctxSize?: number
|
|
366
|
+
mmprojPath?: string
|
|
365
367
|
readinessTimeoutMs?: number
|
|
366
368
|
pollMs?: number
|
|
367
369
|
deps?: LlamaCppStartDeps
|
|
368
370
|
}): Promise<LlamaCppStartResult> {
|
|
369
371
|
const host = args.host ?? DEFAULT_LLAMA_HOST
|
|
370
372
|
const initialStatus = await servedModelStatus(host, args.modelAlias)
|
|
371
|
-
if (initialStatus.state === 'ready')
|
|
373
|
+
if (initialStatus.state === 'ready') {
|
|
374
|
+
if (args.mmprojPath) {
|
|
375
|
+
const pid = await readPidFile()
|
|
376
|
+
if (!pid) {
|
|
377
|
+
return startFailure('untracked-server', {
|
|
378
|
+
detail: 'A llama-server is already serving this alias but ethagent did not launch it, so we cannot apply the vision projector. Stop the external process and reopen ethagent.',
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return { ok: true, alreadyRunning: true }
|
|
383
|
+
}
|
|
372
384
|
if (initialStatus.state === 'different') {
|
|
373
385
|
return startFailure('different-model-running', {
|
|
374
386
|
servedModels: initialStatus.models,
|
|
375
387
|
})
|
|
376
388
|
}
|
|
377
389
|
|
|
390
|
+
const accessFn = args.deps?.access ?? fs.access
|
|
378
391
|
try {
|
|
379
|
-
await (args.
|
|
392
|
+
await accessFn(args.modelPath)
|
|
380
393
|
} catch {
|
|
381
394
|
return startFailure('model-file-missing', { detail: args.modelPath })
|
|
382
395
|
}
|
|
383
396
|
|
|
397
|
+
if (args.mmprojPath) {
|
|
398
|
+
try {
|
|
399
|
+
await accessFn(args.mmprojPath)
|
|
400
|
+
} catch {
|
|
401
|
+
return startFailure('model-file-missing', { detail: args.mmprojPath })
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
384
405
|
const binaryPath = args.deps?.binaryPath ?? (await findAndPersistLlamaCppServer()).path
|
|
385
406
|
if (!binaryPath) {
|
|
386
407
|
return startFailure('runner-not-installed')
|
|
@@ -390,21 +411,23 @@ export async function startLlamaCppServer(args: {
|
|
|
390
411
|
const listenHost = url.hostname || '127.0.0.1'
|
|
391
412
|
const port = url.port || (url.protocol === 'https:' ? '443' : '8080')
|
|
392
413
|
const spawnImpl = args.deps?.spawnImpl ?? spawn
|
|
414
|
+
const spawnArgs: string[] = [
|
|
415
|
+
'-m',
|
|
416
|
+
args.modelPath,
|
|
417
|
+
'--host',
|
|
418
|
+
listenHost,
|
|
419
|
+
'--port',
|
|
420
|
+
port,
|
|
421
|
+
'--alias',
|
|
422
|
+
args.modelAlias,
|
|
423
|
+
'--ctx-size',
|
|
424
|
+
String(args.ctxSize ?? 32768),
|
|
425
|
+
'--jinja',
|
|
426
|
+
]
|
|
427
|
+
if (args.mmprojPath) spawnArgs.push('--mmproj', args.mmprojPath)
|
|
393
428
|
let child: ReturnType<typeof spawn>
|
|
394
429
|
try {
|
|
395
|
-
child = spawnImpl(binaryPath,
|
|
396
|
-
'-m',
|
|
397
|
-
args.modelPath,
|
|
398
|
-
'--host',
|
|
399
|
-
listenHost,
|
|
400
|
-
'--port',
|
|
401
|
-
port,
|
|
402
|
-
'--alias',
|
|
403
|
-
args.modelAlias,
|
|
404
|
-
'--ctx-size',
|
|
405
|
-
String(args.ctxSize ?? 32768),
|
|
406
|
-
'--jinja',
|
|
407
|
-
], {
|
|
430
|
+
child = spawnImpl(binaryPath, spawnArgs, {
|
|
408
431
|
detached: true,
|
|
409
432
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
410
433
|
windowsHide: true,
|
|
@@ -424,6 +447,9 @@ export async function startLlamaCppServer(args: {
|
|
|
424
447
|
})
|
|
425
448
|
})
|
|
426
449
|
child.unref()
|
|
450
|
+
if (typeof child.pid === 'number') {
|
|
451
|
+
await writePidFile(child.pid).catch(() => {})
|
|
452
|
+
}
|
|
427
453
|
|
|
428
454
|
const ready = await waitForServedModel({
|
|
429
455
|
host,
|
|
@@ -468,6 +494,73 @@ async function waitForServedModel(args: {
|
|
|
468
494
|
return startFailure('readiness-timeout')
|
|
469
495
|
}
|
|
470
496
|
|
|
497
|
+
function pidFilePath(): string {
|
|
498
|
+
return path.join(getConfigDir(), 'llamacpp.pid')
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function writePidFile(pid: number): Promise<void> {
|
|
502
|
+
await ensureConfigDir()
|
|
503
|
+
await atomicWriteText(pidFilePath(), String(pid))
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function readPidFile(): Promise<number | null> {
|
|
507
|
+
try {
|
|
508
|
+
const raw = await fs.readFile(pidFilePath(), 'utf8')
|
|
509
|
+
const pid = Number.parseInt(raw.trim(), 10)
|
|
510
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null
|
|
511
|
+
} catch {
|
|
512
|
+
return null
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function clearPidFile(): Promise<void> {
|
|
517
|
+
await fs.rm(pidFilePath(), { force: true }).catch(() => {})
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export async function stopLlamaCppServer(args: {
|
|
521
|
+
host?: string
|
|
522
|
+
timeoutMs?: number
|
|
523
|
+
pollMs?: number
|
|
524
|
+
killImpl?: (pid: number, signal?: NodeJS.Signals | number) => void
|
|
525
|
+
} = {}): Promise<
|
|
526
|
+
| { ok: true; stopped: boolean; reason?: 'untracked-server'; servedModels?: string[] }
|
|
527
|
+
| { ok: false; message: string }
|
|
528
|
+
> {
|
|
529
|
+
const pid = await readPidFile()
|
|
530
|
+
if (!pid) {
|
|
531
|
+
const host = args.host ?? DEFAULT_LLAMA_HOST
|
|
532
|
+
const { up, models } = await fetchServedModels(host, 1500)
|
|
533
|
+
if (up && models.length > 0) {
|
|
534
|
+
return { ok: true, stopped: false, reason: 'untracked-server', servedModels: models }
|
|
535
|
+
}
|
|
536
|
+
return { ok: true, stopped: false }
|
|
537
|
+
}
|
|
538
|
+
const kill = args.killImpl ?? ((p, signal) => process.kill(p, signal))
|
|
539
|
+
try {
|
|
540
|
+
kill(pid, 'SIGTERM')
|
|
541
|
+
} catch (err: unknown) {
|
|
542
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
543
|
+
if (code === 'ESRCH') {
|
|
544
|
+
await clearPidFile()
|
|
545
|
+
return { ok: true, stopped: false }
|
|
546
|
+
}
|
|
547
|
+
return { ok: false, message: (err as Error).message }
|
|
548
|
+
}
|
|
549
|
+
const host = args.host ?? DEFAULT_LLAMA_HOST
|
|
550
|
+
const deadline = Date.now() + (args.timeoutMs ?? 5000)
|
|
551
|
+
const pollMs = args.pollMs ?? 250
|
|
552
|
+
while (Date.now() < deadline) {
|
|
553
|
+
const status = await servedModelStatus(host, '__nothing__')
|
|
554
|
+
if (status.state === 'not-up' || status.models.length === 0) {
|
|
555
|
+
await clearPidFile()
|
|
556
|
+
return { ok: true, stopped: true }
|
|
557
|
+
}
|
|
558
|
+
await new Promise<void>(resolve => setTimeout(resolve, pollMs))
|
|
559
|
+
}
|
|
560
|
+
await clearPidFile()
|
|
561
|
+
return { ok: true, stopped: true }
|
|
562
|
+
}
|
|
563
|
+
|
|
471
564
|
async function servedModelStatus(host: string, modelAlias: string): Promise<
|
|
472
565
|
| { state: 'not-up'; models: string[] }
|
|
473
566
|
| { state: 'ready'; models: string[] }
|
|
@@ -507,6 +600,8 @@ function startFailureMessage(code: LlamaCppStartFailureCode, servedModels: strin
|
|
|
507
600
|
return 'local runner closed before becoming ready'
|
|
508
601
|
case 'readiness-timeout':
|
|
509
602
|
return 'local runner is still loading or did not answer in time'
|
|
603
|
+
case 'untracked-server':
|
|
604
|
+
return detail ?? 'a llama-server is already running and ethagent did not launch it; cannot apply the vision encoder until that process is stopped'
|
|
510
605
|
}
|
|
511
606
|
}
|
|
512
607
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
startLlamaCppServer,
|
|
3
|
+
stopLlamaCppServer,
|
|
3
4
|
type LlamaCppStartFailureCode,
|
|
4
5
|
type LlamaCppStartResult,
|
|
5
6
|
} from './llamacpp.js'
|
|
@@ -21,9 +22,13 @@ export type LlamaCppPreflightDeps = {
|
|
|
21
22
|
fetchImpl?: typeof fetch
|
|
22
23
|
findLocalModel?: typeof findLocalHfModel
|
|
23
24
|
startServer?: typeof startLlamaCppServer
|
|
25
|
+
stopServer?: typeof stopLlamaCppServer
|
|
24
26
|
timeoutMs?: number
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
const UNTRACKED_VISION_DETAIL =
|
|
30
|
+
'A llama-server is already serving this alias but ethagent did not launch it, so we cannot apply the vision projector. Stop the external process and reopen ethagent.'
|
|
31
|
+
|
|
27
32
|
type ModelsProbe =
|
|
28
33
|
| { up: true; models: string[] }
|
|
29
34
|
| { up: false; models: [] }
|
|
@@ -50,18 +55,31 @@ export async function ensureLlamaCppRunnerReady(
|
|
|
50
55
|
|
|
51
56
|
const probe = await probeLlamaCppModels(baseUrl, deps)
|
|
52
57
|
if (probe.up) {
|
|
53
|
-
if (probe.models.length
|
|
54
|
-
return {
|
|
58
|
+
if (probe.models.length > 0 && !probe.models.includes(config.model)) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
code: 'different-model-running',
|
|
62
|
+
message: formatPreflightFailure(
|
|
63
|
+
'local runner is serving a different model',
|
|
64
|
+
config.model,
|
|
65
|
+
`a different local model is already running (${probe.models.join(', ')}); stop it before switching models`,
|
|
66
|
+
),
|
|
67
|
+
servedModels: probe.models,
|
|
68
|
+
}
|
|
55
69
|
}
|
|
56
|
-
return {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
if (!local.mmprojPath) return { ok: true, alreadyRunning: true }
|
|
71
|
+
const stopped = await (deps.stopServer ?? stopLlamaCppServer)().catch(() => null)
|
|
72
|
+
if (stopped && stopped.ok && stopped.reason === 'untracked-server') {
|
|
73
|
+
return withPreflightMessage(
|
|
74
|
+
{
|
|
75
|
+
ok: false,
|
|
76
|
+
code: 'untracked-server',
|
|
77
|
+
message: UNTRACKED_VISION_DETAIL,
|
|
78
|
+
detail: UNTRACKED_VISION_DETAIL,
|
|
79
|
+
servedModels: stopped.servedModels,
|
|
80
|
+
},
|
|
81
|
+
local,
|
|
82
|
+
)
|
|
65
83
|
}
|
|
66
84
|
}
|
|
67
85
|
|
|
@@ -69,6 +87,7 @@ export async function ensureLlamaCppRunnerReady(
|
|
|
69
87
|
modelPath: local.localPath,
|
|
70
88
|
modelAlias: local.id,
|
|
71
89
|
host: llamaCppServerHostFromBaseUrl(baseUrl),
|
|
90
|
+
mmprojPath: local.mmprojPath,
|
|
72
91
|
})
|
|
73
92
|
if (result.ok) return { ok: true, alreadyRunning: result.alreadyRunning }
|
|
74
93
|
return withPreflightMessage(result, local)
|
|
@@ -23,6 +23,9 @@ export type LocalHfPickerModel = {
|
|
|
23
23
|
risk: HfRisk
|
|
24
24
|
task: HfTask
|
|
25
25
|
status: 'ready' | 'incomplete'
|
|
26
|
+
mmprojPath?: string
|
|
27
|
+
mmprojAvailable?: boolean
|
|
28
|
+
mmprojSizeBytes?: number
|
|
26
29
|
}
|
|
27
30
|
|
|
28
31
|
export type CloudCredentialKind = 'apikey' | 'oauth'
|
|
@@ -185,12 +188,22 @@ function appendHfModelOptions(
|
|
|
185
188
|
displayName: model.displayName,
|
|
186
189
|
maxLength,
|
|
187
190
|
})
|
|
191
|
+
const tags = ['Installed']
|
|
192
|
+
if (model.mmprojPath) tags.push('Vision encoder loaded')
|
|
188
193
|
options.push(rowOption(
|
|
189
194
|
`hf:${id}`,
|
|
190
195
|
contextFitLabel('llamacpp', id, `${active ? '* ' : ' '}${displayName}`, context.contextFit),
|
|
191
196
|
undefined,
|
|
192
|
-
modelMetadataSubtext(size,
|
|
197
|
+
modelMetadataSubtext(size, tags),
|
|
193
198
|
))
|
|
199
|
+
if (model.mmprojAvailable && !model.mmprojPath) {
|
|
200
|
+
const projectorSize = model.mmprojSizeBytes ? ` (+${formatSize(model.mmprojSizeBytes)})` : ''
|
|
201
|
+
options.push(rowOption(
|
|
202
|
+
`hfmmproj:${id}`,
|
|
203
|
+
` + Add Vision Encoder${projectorSize}`,
|
|
204
|
+
'Enable image input on this local model',
|
|
205
|
+
))
|
|
206
|
+
}
|
|
194
207
|
}
|
|
195
208
|
}
|
|
196
209
|
|
|
@@ -4,6 +4,7 @@ import { ProviderError } from './contracts.js'
|
|
|
4
4
|
import { providerErrorFromResponse } from './errors.js'
|
|
5
5
|
import { fetchWithRetryStreamEvents } from './retry.js'
|
|
6
6
|
import { iterSseEvents } from './sse.js'
|
|
7
|
+
import { hasImageBlocks, ImageLoadError, loadImageBlock } from '../utils/images.js'
|
|
7
8
|
|
|
8
9
|
export type AnthropicToolDefinition = {
|
|
9
10
|
name: string
|
|
@@ -75,7 +76,22 @@ export class AnthropicProvider implements Provider {
|
|
|
75
76
|
return
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
if (hasImageBlocks(messages) && !supportsAnthropicImages(this.model)) {
|
|
80
|
+
yield { type: 'error', message: `image input is not enabled for ${this.model}` }
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let split: { system?: string; conversation: Awaited<ReturnType<typeof splitMessages>>['conversation'] }
|
|
85
|
+
try {
|
|
86
|
+
split = await splitMessages(messages)
|
|
87
|
+
} catch (err: unknown) {
|
|
88
|
+
if (err instanceof ImageLoadError) {
|
|
89
|
+
yield { type: 'error', message: err.message }
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
throw err
|
|
93
|
+
}
|
|
94
|
+
const { system, conversation } = split
|
|
79
95
|
|
|
80
96
|
let response: Response
|
|
81
97
|
try {
|
|
@@ -195,22 +211,24 @@ export class AnthropicProvider implements Provider {
|
|
|
195
211
|
}
|
|
196
212
|
}
|
|
197
213
|
|
|
198
|
-
function splitMessages(messages: Message[]): {
|
|
214
|
+
async function splitMessages(messages: Message[]): Promise<{
|
|
199
215
|
system?: string
|
|
200
216
|
conversation: Array<{
|
|
201
217
|
role: 'user' | 'assistant'
|
|
202
218
|
content: Array<
|
|
203
219
|
| { type: 'text'; text: string }
|
|
220
|
+
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
|
|
204
221
|
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
|
|
205
222
|
| { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean }
|
|
206
223
|
>
|
|
207
224
|
}>
|
|
208
|
-
} {
|
|
225
|
+
}> {
|
|
209
226
|
const systemParts: string[] = []
|
|
210
227
|
const conversation: Array<{
|
|
211
228
|
role: 'user' | 'assistant'
|
|
212
229
|
content: Array<
|
|
213
230
|
| { type: 'text'; text: string }
|
|
231
|
+
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
|
|
214
232
|
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
|
|
215
233
|
| { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean }
|
|
216
234
|
>
|
|
@@ -226,11 +244,16 @@ function splitMessages(messages: Message[]): {
|
|
|
226
244
|
}
|
|
227
245
|
conversation.push({
|
|
228
246
|
role: message.role,
|
|
229
|
-
content: blocks.map(block => {
|
|
247
|
+
content: await Promise.all(blocks.map(async block => {
|
|
230
248
|
if (block.type === 'text') return { type: 'text', text: block.text }
|
|
249
|
+
if (block.type === 'image') {
|
|
250
|
+
const loaded = await loadImageBlock(block)
|
|
251
|
+
if (!loaded.dataBase64 || !loaded.mimeType) throw new Error(`could not load image: ${block.path}`)
|
|
252
|
+
return { type: 'image', source: { type: 'base64', media_type: loaded.mimeType, data: loaded.dataBase64 } }
|
|
253
|
+
}
|
|
231
254
|
if (block.type === 'tool_use') return { type: 'tool_use', id: block.id, name: block.name, input: block.input }
|
|
232
255
|
return { type: 'tool_result', tool_use_id: block.toolUseId, content: block.content, is_error: block.isError }
|
|
233
|
-
}),
|
|
256
|
+
})),
|
|
234
257
|
})
|
|
235
258
|
}
|
|
236
259
|
|
|
@@ -251,6 +274,14 @@ function normalizeBlocks(content: Message['content']): MessageContentBlock[] {
|
|
|
251
274
|
})
|
|
252
275
|
}
|
|
253
276
|
|
|
277
|
+
export function supportsAnthropicImages(model: string): boolean {
|
|
278
|
+
const normalized = model.toLowerCase()
|
|
279
|
+
return normalized.includes('claude-3')
|
|
280
|
+
|| normalized.includes('claude-sonnet-4')
|
|
281
|
+
|| normalized.includes('claude-opus-4')
|
|
282
|
+
|| normalized.includes('claude-haiku-4')
|
|
283
|
+
}
|
|
284
|
+
|
|
254
285
|
function normalizeStopReason(value?: string): 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'unknown' {
|
|
255
286
|
if (value === 'end_turn' || value === 'tool_use' || value === 'max_tokens' || value === 'stop_sequence') {
|
|
256
287
|
return value
|
|
@@ -8,6 +8,14 @@ export type TextBlock = {
|
|
|
8
8
|
text: string
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export type ImageBlock = {
|
|
12
|
+
type: 'image'
|
|
13
|
+
path: string
|
|
14
|
+
mimeType?: string
|
|
15
|
+
url?: string
|
|
16
|
+
dataBase64?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
export type ToolUseBlock = {
|
|
12
20
|
type: 'tool_use'
|
|
13
21
|
id: string
|
|
@@ -22,7 +30,7 @@ export type ToolResultBlock = {
|
|
|
22
30
|
isError?: boolean
|
|
23
31
|
}
|
|
24
32
|
|
|
25
|
-
export type MessageContentBlock = TextBlock | ToolUseBlock | ToolResultBlock
|
|
33
|
+
export type MessageContentBlock = TextBlock | ImageBlock | ToolUseBlock | ToolResultBlock
|
|
26
34
|
|
|
27
35
|
export type Message = {
|
|
28
36
|
role: Role
|