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.
@@ -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
- await upsertLocalHfModel(modelFromPlan(plan, hash.digest('hex'), 'ready'))
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(plan: HfDownloadPlan, sha256: string | undefined, status: LocalHfStatus): LocalHfModel {
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
 
@@ -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') return { ok: true, alreadyRunning: true }
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.deps?.access ?? fs.access)(args.modelPath)
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 === 0 || probe.models.includes(config.model)) {
54
- return { ok: true, alreadyRunning: true }
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
- ok: false,
58
- code: 'different-model-running',
59
- message: formatPreflightFailure(
60
- 'local runner is serving a different model',
61
- config.model,
62
- `a different local model is already running (${probe.models.join(', ')}); stop it before switching models`,
63
- ),
64
- servedModels: probe.models,
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, ['Installed']),
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
- const { system, conversation } = splitMessages(messages)
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