ethagent 0.2.1 → 1.0.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.
Files changed (143) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +114 -32
  3. package/bin/ethagent.js +11 -2
  4. package/package.json +25 -7
  5. package/src/app/FirstRun.tsx +412 -0
  6. package/src/app/hooks/useCancelRequest.ts +22 -0
  7. package/src/app/hooks/useDoublePress.ts +46 -0
  8. package/src/app/hooks/useExitOnCtrlC.ts +36 -0
  9. package/src/app/input/AppInputProvider.tsx +116 -0
  10. package/src/app/input/appInputParser.ts +279 -0
  11. package/src/app/keybindings/KeybindingProvider.tsx +134 -0
  12. package/src/app/keybindings/resolver.ts +42 -0
  13. package/src/app/keybindings/types.ts +26 -0
  14. package/src/chat/ChatBottomPane.tsx +280 -0
  15. package/src/chat/ChatInput.tsx +722 -0
  16. package/src/chat/ChatScreen.tsx +1575 -0
  17. package/src/chat/ContextLimitView.tsx +95 -0
  18. package/src/chat/ContinuityEditReviewView.tsx +48 -0
  19. package/src/chat/ConversationStack.tsx +47 -0
  20. package/src/chat/CopyPicker.tsx +52 -0
  21. package/src/chat/MessageList.tsx +609 -0
  22. package/src/chat/PermissionPrompt.tsx +153 -0
  23. package/src/chat/PermissionsView.tsx +159 -0
  24. package/src/chat/PlanApprovalView.tsx +91 -0
  25. package/src/chat/ResumeView.tsx +267 -0
  26. package/src/chat/RewindView.tsx +386 -0
  27. package/src/chat/SessionStatus.tsx +51 -0
  28. package/src/chat/TranscriptView.tsx +202 -0
  29. package/src/chat/chatInputState.ts +247 -0
  30. package/src/chat/chatPaste.ts +49 -0
  31. package/src/chat/chatScreenUtils.ts +187 -0
  32. package/src/chat/chatSessionState.ts +142 -0
  33. package/src/chat/chatTurnOrchestrator.ts +701 -0
  34. package/src/chat/commands.ts +673 -0
  35. package/src/chat/textCursor.ts +202 -0
  36. package/src/chat/toolResultDisplay.ts +8 -0
  37. package/src/chat/transcriptViewport.ts +247 -0
  38. package/src/cli/ResetConfirmView.tsx +61 -0
  39. package/src/cli/main.tsx +177 -0
  40. package/src/cli/preview.tsx +19 -0
  41. package/src/cli/reset.ts +106 -0
  42. package/src/identity/continuity/editor.ts +149 -0
  43. package/src/identity/continuity/envelope.ts +345 -0
  44. package/src/identity/continuity/history.ts +153 -0
  45. package/src/identity/continuity/privateEdit.ts +334 -0
  46. package/src/identity/continuity/publicSkills.ts +173 -0
  47. package/src/identity/continuity/snapshots.ts +183 -0
  48. package/src/identity/continuity/storage.ts +507 -0
  49. package/src/identity/crypto/backupEnvelope.ts +486 -0
  50. package/src/identity/crypto/eth.ts +137 -0
  51. package/src/identity/hub/IdentityHub.tsx +845 -0
  52. package/src/identity/hub/identityHubEffects.ts +1100 -0
  53. package/src/identity/hub/identityHubModel.ts +291 -0
  54. package/src/identity/hub/identityHubReducer.ts +209 -0
  55. package/src/identity/hub/screens/BusyScreen.tsx +26 -0
  56. package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -0
  57. package/src/identity/hub/screens/CreateFlow.tsx +206 -0
  58. package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
  59. package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
  60. package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
  61. package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
  62. package/src/identity/hub/screens/MenuScreen.tsx +117 -0
  63. package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
  64. package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
  65. package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
  66. package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
  67. package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
  68. package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
  69. package/src/identity/profile/imagePicker.ts +180 -0
  70. package/src/identity/registry/erc8004.ts +1106 -0
  71. package/src/identity/registry/registryConfig.ts +69 -0
  72. package/src/identity/storage/ipfs.ts +212 -0
  73. package/src/identity/storage/pinataJwt.ts +53 -0
  74. package/src/identity/wallet/browserWallet.ts +393 -0
  75. package/src/identity/wallet/wallet-page/wallet.html +1082 -0
  76. package/src/mcp/approvals.ts +113 -0
  77. package/src/mcp/config.ts +235 -0
  78. package/src/mcp/manager.ts +541 -0
  79. package/src/mcp/names.ts +19 -0
  80. package/src/mcp/output.ts +96 -0
  81. package/src/models/ModelPicker.tsx +1446 -0
  82. package/src/models/catalog.ts +296 -0
  83. package/src/models/huggingface.ts +651 -0
  84. package/src/models/llamacpp.ts +810 -0
  85. package/src/models/llamacppPreflight.ts +150 -0
  86. package/src/models/modelDisplay.ts +105 -0
  87. package/src/models/modelPickerOptions.ts +421 -0
  88. package/src/models/modelRecommendation.ts +140 -0
  89. package/src/models/runtimeDetection.ts +81 -0
  90. package/src/models/uncensoredCatalog.ts +86 -0
  91. package/src/providers/anthropic.ts +259 -0
  92. package/src/providers/contracts.ts +62 -0
  93. package/src/providers/errors.ts +62 -0
  94. package/src/providers/gemini.ts +152 -0
  95. package/src/providers/openai-chat.ts +472 -0
  96. package/src/providers/registry.ts +42 -0
  97. package/src/providers/retry.ts +58 -0
  98. package/src/providers/sse.ts +93 -0
  99. package/src/runtime/compaction.ts +389 -0
  100. package/src/runtime/cwd.ts +43 -0
  101. package/src/runtime/sessionMode.ts +55 -0
  102. package/src/runtime/systemPrompt.ts +209 -0
  103. package/src/runtime/toolClaimGuards.ts +143 -0
  104. package/src/runtime/toolExecution.ts +304 -0
  105. package/src/runtime/toolIntent.ts +163 -0
  106. package/src/runtime/turn.ts +858 -0
  107. package/src/storage/atomicWrite.ts +68 -0
  108. package/src/storage/config.ts +189 -0
  109. package/src/storage/factoryReset.ts +130 -0
  110. package/src/storage/history.ts +58 -0
  111. package/src/storage/identity.ts +99 -0
  112. package/src/storage/permissions.ts +76 -0
  113. package/src/storage/rewind.ts +246 -0
  114. package/src/storage/secrets.ts +181 -0
  115. package/src/storage/sessionExport.ts +49 -0
  116. package/src/storage/sessions.ts +482 -0
  117. package/src/tools/bashSafety.ts +174 -0
  118. package/src/tools/bashTool.ts +140 -0
  119. package/src/tools/changeDirectoryTool.ts +213 -0
  120. package/src/tools/contracts.ts +179 -0
  121. package/src/tools/deleteFileTool.ts +111 -0
  122. package/src/tools/editTool.ts +160 -0
  123. package/src/tools/editUtils.ts +170 -0
  124. package/src/tools/listDirectoryTool.ts +55 -0
  125. package/src/tools/mcpResourceTools.ts +95 -0
  126. package/src/tools/permissionRules.ts +85 -0
  127. package/src/tools/privateContinuityEditTool.ts +178 -0
  128. package/src/tools/privateContinuityReadTool.ts +107 -0
  129. package/src/tools/readTool.ts +85 -0
  130. package/src/tools/registry.ts +67 -0
  131. package/src/tools/writeFileTool.ts +142 -0
  132. package/src/ui/BrandSplash.tsx +193 -0
  133. package/src/ui/ProgressBar.tsx +34 -0
  134. package/src/ui/Select.tsx +143 -0
  135. package/src/ui/Spinner.tsx +269 -0
  136. package/src/ui/Surface.tsx +47 -0
  137. package/src/ui/TextInput.tsx +97 -0
  138. package/src/ui/theme.ts +59 -0
  139. package/src/utils/clipboard.ts +216 -0
  140. package/src/utils/markdownSegments.ts +51 -0
  141. package/src/utils/messages.ts +35 -0
  142. package/src/utils/withRetry.ts +280 -0
  143. package/src/cli.tsx +0 -147
@@ -0,0 +1,140 @@
1
+ import {
2
+ fileFormat,
3
+ quantizationFromFilename,
4
+ type HuggingFaceRepoInfo,
5
+ type HuggingFaceSibling,
6
+ } from './huggingface.js'
7
+ import type { SpecSnapshot } from './runtimeDetection.js'
8
+
9
+ const GB = 1024 * 1024 * 1024
10
+
11
+ /** Featured local model repo for first-run setup and the model picker catalog. */
12
+ export const FEATURED_HF_REPO = 'HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive'
13
+ export const FEATURED_HF_REPO_URL = `https://huggingface.co/${FEATURED_HF_REPO}`
14
+
15
+ export type GgufMachineFit = 'fits' | 'tight' | 'too-large' | 'unknown'
16
+
17
+ export type GgufFileRecommendation = {
18
+ file: HuggingFaceSibling
19
+ fit: GgufMachineFit
20
+ score: number
21
+ budgetBytes: number
22
+ estimatedRequiredBytes?: number
23
+ }
24
+
25
+ export function recommendGgufFile(
26
+ repo: HuggingFaceRepoInfo,
27
+ files: HuggingFaceSibling[],
28
+ spec: SpecSnapshot,
29
+ ): GgufFileRecommendation | null {
30
+ return orderGgufFilesForSpec(repo, files, spec)[0] ?? null
31
+ }
32
+
33
+ export function orderGgufFilesForSpec(
34
+ repo: HuggingFaceRepoInfo,
35
+ files: HuggingFaceSibling[],
36
+ spec: SpecSnapshot,
37
+ ): GgufFileRecommendation[] {
38
+ return files
39
+ .filter(file => fileFormat(file.filename) === 'gguf')
40
+ .map(file => scoreGgufFile(repo, file, spec))
41
+ .sort((a, b) =>
42
+ b.score - a.score
43
+ || fitRank(b.fit) - fitRank(a.fit)
44
+ || (a.file.sizeBytes ?? Number.MAX_SAFE_INTEGER) - (b.file.sizeBytes ?? Number.MAX_SAFE_INTEGER)
45
+ || a.file.filename.localeCompare(b.file.filename),
46
+ )
47
+ }
48
+
49
+ export function estimateGgufMachineFit(sizeBytes: number | undefined, spec: SpecSnapshot): {
50
+ fit: GgufMachineFit
51
+ budgetBytes: number
52
+ estimatedRequiredBytes?: number
53
+ } {
54
+ const budgetBytes = ggufBudgetBytes(spec)
55
+ if (!sizeBytes || sizeBytes <= 0) return { fit: 'unknown', budgetBytes }
56
+ const estimatedRequiredBytes = Math.ceil(sizeBytes * 1.25 + GB)
57
+ if (estimatedRequiredBytes <= budgetBytes) return { fit: 'fits', budgetBytes, estimatedRequiredBytes }
58
+ if (estimatedRequiredBytes <= budgetBytes * 1.15) return { fit: 'tight', budgetBytes, estimatedRequiredBytes }
59
+ return { fit: 'too-large', budgetBytes, estimatedRequiredBytes }
60
+ }
61
+
62
+ function scoreGgufFile(
63
+ repo: HuggingFaceRepoInfo,
64
+ file: HuggingFaceSibling,
65
+ spec: SpecSnapshot,
66
+ ): GgufFileRecommendation {
67
+ const fit = estimateGgufMachineFit(file.sizeBytes, spec)
68
+ const lower = `${repo.repoId} ${file.filename} ${repo.tags.join(' ')}`.toLowerCase()
69
+ const score =
70
+ fitScore(fit.fit)
71
+ + taskScore(lower)
72
+ + quantizationScore(quantizationFromFilename(file.filename))
73
+ + sizeScore(file.sizeBytes, fit.fit)
74
+
75
+ return {
76
+ file,
77
+ fit: fit.fit,
78
+ budgetBytes: fit.budgetBytes,
79
+ estimatedRequiredBytes: fit.estimatedRequiredBytes,
80
+ score,
81
+ }
82
+ }
83
+
84
+ function ggufBudgetBytes(spec: SpecSnapshot): number {
85
+ if (spec.isAppleSilicon) return Math.floor(spec.effectiveRamBytes * 0.7)
86
+ const cpuBudget = Math.floor(spec.effectiveRamBytes * 0.55)
87
+ if (spec.gpuVramBytes !== null && spec.gpuVramBytes >= 8 * GB) {
88
+ return Math.max(cpuBudget, Math.floor(spec.gpuVramBytes * 0.85))
89
+ }
90
+ return cpuBudget
91
+ }
92
+
93
+ function fitScore(fit: GgufMachineFit): number {
94
+ switch (fit) {
95
+ case 'fits': return 1000
96
+ case 'tight': return 600
97
+ case 'unknown': return 250
98
+ case 'too-large': return -1000
99
+ }
100
+ }
101
+
102
+ function fitRank(fit: GgufMachineFit): number {
103
+ switch (fit) {
104
+ case 'fits': return 3
105
+ case 'tight': return 2
106
+ case 'unknown': return 1
107
+ case 'too-large': return 0
108
+ }
109
+ }
110
+
111
+ function taskScore(text: string): number {
112
+ if (/(embed|embedding|rerank)/.test(text)) return -350
113
+ if (/(vision|vlm|multimodal)/.test(text)) return -250
114
+ if (/(instruct|chat|assistant)/.test(text)) return 250
115
+ if (/(code|coder|coding)/.test(text)) return 120
116
+ if (/(^|[-_\s])base($|[-_\s])/.test(text)) return -100
117
+ return 0
118
+ }
119
+
120
+ function quantizationScore(quantization: string | undefined): number {
121
+ if (!quantization) return 0
122
+ if (quantization === 'Q8_0') return 210
123
+ if (quantization === 'Q6_K') return 195
124
+ if (quantization === 'Q5_K_M') return 185
125
+ if (quantization === 'Q5_K_S') return 175
126
+ if (quantization.startsWith('Q5')) return 165
127
+ if (quantization === 'Q4_K_M') return 155
128
+ if (quantization === 'Q4_K_S') return 140
129
+ if (quantization.startsWith('Q4')) return 125
130
+ if (quantization.startsWith('IQ4')) return 115
131
+ if (quantization.startsWith('IQ3') || quantization.startsWith('Q3')) return 85
132
+ if (quantization === 'F16' || quantization === 'BF16') return 150
133
+ if (quantization === 'F32') return 90
134
+ return 50
135
+ }
136
+
137
+ function sizeScore(sizeBytes: number | undefined, fit: GgufMachineFit): number {
138
+ if (!sizeBytes || fit === 'too-large') return 0
139
+ return Math.min(sizeBytes / GB, 20) * 4
140
+ }
@@ -0,0 +1,81 @@
1
+ import os from 'node:os'
2
+ import { spawn } from 'node:child_process'
3
+
4
+ export type SpecSnapshot = {
5
+ platform: NodeJS.Platform
6
+ arch: string
7
+ cpuCores: number
8
+ totalRamBytes: number
9
+ effectiveRamBytes: number
10
+ isAppleSilicon: boolean
11
+ gpuVramBytes: number | null
12
+ }
13
+
14
+ function runCommand(cmd: string, args: string[], timeoutMs = 2000): Promise<{ code: number; stdout: string; stderr: string } | null> {
15
+ return new Promise(resolve => {
16
+ let settled = false
17
+ let child: ReturnType<typeof spawn>
18
+ try {
19
+ child = spawn(cmd, args, { windowsHide: true })
20
+ } catch {
21
+ resolve(null)
22
+ return
23
+ }
24
+ let stdout = ''
25
+ let stderr = ''
26
+ const timer = setTimeout(() => {
27
+ if (settled) return
28
+ settled = true
29
+ try { child.kill() } catch { void 0 }
30
+ resolve(null)
31
+ }, timeoutMs)
32
+
33
+ child.stdout?.on('data', d => { stdout += d.toString() })
34
+ child.stderr?.on('data', d => { stderr += d.toString() })
35
+ child.on('error', () => {
36
+ if (settled) return
37
+ settled = true
38
+ clearTimeout(timer)
39
+ resolve(null)
40
+ })
41
+ child.on('close', code => {
42
+ if (settled) return
43
+ settled = true
44
+ clearTimeout(timer)
45
+ resolve({ code: code ?? -1, stdout, stderr })
46
+ })
47
+ })
48
+ }
49
+
50
+ async function detectNvidiaVram(): Promise<number | null> {
51
+ const result = await runCommand('nvidia-smi', ['--query-gpu=memory.total', '--format=csv,noheader,nounits'])
52
+ if (!result || result.code !== 0) return null
53
+ const firstLine = result.stdout.split('\n').map(l => l.trim()).find(Boolean)
54
+ if (!firstLine) return null
55
+ const mib = Number.parseInt(firstLine, 10)
56
+ if (!Number.isFinite(mib) || mib <= 0) return null
57
+ return mib * 1024 * 1024
58
+ }
59
+
60
+ export async function detectSpec(): Promise<SpecSnapshot> {
61
+ const platform = process.platform
62
+ const arch = process.arch
63
+ const cpuCores = os.cpus().length
64
+ const totalRamBytes = os.totalmem()
65
+ const isAppleSilicon = platform === 'darwin' && arch === 'arm64'
66
+ const effectiveRamBytes = isAppleSilicon ? Math.floor(totalRamBytes * 0.75) : totalRamBytes
67
+
68
+ const [gpuVramBytes] = await Promise.all([
69
+ isAppleSilicon ? Promise.resolve(null) : detectNvidiaVram(),
70
+ ])
71
+
72
+ return {
73
+ platform,
74
+ arch,
75
+ cpuCores,
76
+ totalRamBytes,
77
+ effectiveRamBytes,
78
+ isAppleSilicon,
79
+ gpuVramBytes,
80
+ }
81
+ }
@@ -0,0 +1,86 @@
1
+ import {
2
+ fetchHuggingFaceRepoInfo,
3
+ ggufFiles,
4
+ localModelId,
5
+ type HuggingFaceRepoInfo,
6
+ type HuggingFaceSibling,
7
+ type LocalHfModel,
8
+ } from './huggingface.js'
9
+ import {
10
+ FEATURED_HF_REPO,
11
+ estimateGgufMachineFit,
12
+ recommendGgufFile,
13
+ type GgufMachineFit,
14
+ } from './modelRecommendation.js'
15
+ import type { SpecSnapshot } from './runtimeDetection.js'
16
+
17
+ export type UncensoredCatalogEntry = {
18
+ repo: HuggingFaceRepoInfo
19
+ file: HuggingFaceSibling
20
+ fit: GgufMachineFit
21
+ recommended: boolean
22
+ installed: boolean
23
+ }
24
+
25
+ type FetchImpl = typeof fetch
26
+
27
+ const GB = 1024 * 1024 * 1024
28
+ const FEATURED_FILE_ORDER = [
29
+ { filename: 'Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-BF16.gguf', fallbackSizeBytes: Math.round(17 * GB) },
30
+ { filename: 'Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q8_0.gguf', fallbackSizeBytes: Math.round(8.9 * GB) },
31
+ { filename: 'Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q6_K.gguf', fallbackSizeBytes: Math.round(6.9 * GB) },
32
+ { filename: 'Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf', fallbackSizeBytes: Math.round(5.3 * GB) },
33
+ { filename: 'mmproj-Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-BF16.gguf', fallbackSizeBytes: Math.round(0.88 * GB) },
34
+ ] as const
35
+
36
+ export async function fetchUncensoredGgufCatalog(args: {
37
+ machineSpec?: SpecSnapshot
38
+ installedModels: LocalHfModel[]
39
+ fetchImpl?: FetchImpl
40
+ limit?: number
41
+ }): Promise<UncensoredCatalogEntry[]> {
42
+ const fetchImpl = args.fetchImpl ?? fetch
43
+ const repos = await Promise.allSettled([
44
+ fetchHuggingFaceRepoInfo({ repoId: FEATURED_HF_REPO }, fetchImpl),
45
+ ])
46
+ const installed = new Set(args.installedModels.filter(model => model.status === 'ready').map(model => model.id))
47
+ const entries: UncensoredCatalogEntry[] = []
48
+
49
+ for (const result of repos) {
50
+ if (result.status !== 'fulfilled') continue
51
+ const repo = result.value
52
+ if (repo.repoId !== FEATURED_HF_REPO) continue
53
+ const files = pickFeaturedFiles(repo)
54
+ if (files.length === 0) continue
55
+ const runnable = files.filter(file => !isVisionEncoder(file.filename))
56
+ const recommendedFilename = args.machineSpec
57
+ ? recommendGgufFile(repo, runnable, args.machineSpec)?.file.filename
58
+ : undefined
59
+ for (const file of files.slice(0, args.limit ?? FEATURED_FILE_ORDER.length)) {
60
+ const recommended = recommendedFilename === file.filename
61
+ entries.push({
62
+ repo,
63
+ file,
64
+ fit: args.machineSpec ? estimateGgufMachineFit(file.sizeBytes, args.machineSpec).fit : 'unknown',
65
+ recommended,
66
+ installed: installed.has(localModelId(repo.repoId, file.filename)),
67
+ })
68
+ }
69
+ }
70
+
71
+ return entries
72
+ }
73
+
74
+ function pickFeaturedFiles(repo: HuggingFaceRepoInfo): HuggingFaceSibling[] {
75
+ const byName = new Map(ggufFiles(repo).map(file => [file.filename, file] as const))
76
+ return FEATURED_FILE_ORDER
77
+ .map(spec => {
78
+ const file = byName.get(spec.filename)
79
+ if (file) return file
80
+ return { filename: spec.filename, sizeBytes: spec.fallbackSizeBytes } satisfies HuggingFaceSibling
81
+ })
82
+ }
83
+
84
+ function isVisionEncoder(filename: string): boolean {
85
+ return filename.toLowerCase().startsWith('mmproj-')
86
+ }
@@ -0,0 +1,259 @@
1
+ import { getKey } from '../storage/secrets.js'
2
+ import type { Message, MessageContentBlock, Provider, ProviderCompleteOptions, StreamEvent } from './contracts.js'
3
+ import { ProviderError } from './contracts.js'
4
+ import { providerErrorFromResponse } from './errors.js'
5
+ import { fetchWithRetryStreamEvents } from './retry.js'
6
+ import { iterSseEvents } from './sse.js'
7
+
8
+ export type AnthropicToolDefinition = {
9
+ name: string
10
+ description: string
11
+ input_schema: {
12
+ type: 'object'
13
+ properties?: Record<string, unknown>
14
+ required?: string[]
15
+ }
16
+ }
17
+
18
+ type AnthropicStreamMessage = {
19
+ type?: string
20
+ message?: {
21
+ usage?: {
22
+ input_tokens?: number
23
+ output_tokens?: number
24
+ }
25
+ }
26
+ usage?: {
27
+ input_tokens?: number
28
+ output_tokens?: number
29
+ }
30
+ delta?: {
31
+ type?: string
32
+ text?: string
33
+ thinking?: string
34
+ stop_reason?: string
35
+ partial_json?: string
36
+ }
37
+ content_block?: {
38
+ type?: string
39
+ id?: string
40
+ name?: string
41
+ input?: Record<string, unknown>
42
+ }
43
+ index?: number
44
+ error?: {
45
+ type?: string
46
+ message?: string
47
+ }
48
+ }
49
+
50
+ const ANTHROPIC_VERSION = '2023-06-01'
51
+ const READ_TIMEOUT_MS = 45_000
52
+ const DEFAULT_MAX_TOKENS = 4096
53
+
54
+ export class AnthropicProvider implements Provider {
55
+ readonly id = 'anthropic' as const
56
+ readonly model: string
57
+ readonly supportsTools: boolean
58
+ private readonly tools: AnthropicToolDefinition[]
59
+
60
+ constructor(opts: { model: string; tools?: AnthropicToolDefinition[] }) {
61
+ this.model = opts.model
62
+ this.tools = opts.tools ?? []
63
+ this.supportsTools = this.tools.length > 0
64
+ }
65
+
66
+ async *complete(
67
+ messages: Message[],
68
+ signal: AbortSignal,
69
+ options: ProviderCompleteOptions = {},
70
+ ): AsyncIterable<StreamEvent> {
71
+ const apiKey = await getKey('anthropic')
72
+ if (!apiKey) {
73
+ const error = new ProviderError('missing API key for anthropic (/doctor to verify)')
74
+ yield { type: 'error', message: error.message }
75
+ return
76
+ }
77
+
78
+ const { system, conversation } = splitMessages(messages)
79
+
80
+ let response: Response
81
+ try {
82
+ response = yield* fetchWithRetryStreamEvents('https://api.anthropic.com/v1/messages', {
83
+ method: 'POST',
84
+ headers: {
85
+ 'content-type': 'application/json',
86
+ accept: 'text/event-stream',
87
+ 'anthropic-version': ANTHROPIC_VERSION,
88
+ 'x-api-key': apiKey,
89
+ },
90
+ body: JSON.stringify({
91
+ model: this.model,
92
+ max_tokens: options.maxTokens ?? DEFAULT_MAX_TOKENS,
93
+ stream: true,
94
+ system,
95
+ messages: conversation,
96
+ tools: this.tools.length > 0 ? this.tools : undefined,
97
+ }),
98
+ }, { signal, rateLimitResetProvider: 'anthropic' })
99
+ } catch (err: unknown) {
100
+ if (signal.aborted) return
101
+ yield { type: 'error', message: (err as Error).message || 'network error' }
102
+ return
103
+ }
104
+
105
+ if (!response.ok) {
106
+ const error = await providerErrorFromResponse(this.id, response)
107
+ yield { type: 'error', message: error.message }
108
+ return
109
+ }
110
+ if (!response.body) {
111
+ yield { type: 'error', message: 'empty response body' }
112
+ return
113
+ }
114
+
115
+ let inputTokens: number | undefined
116
+ let outputTokens: number | undefined
117
+ let stopReason: 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'unknown' = 'unknown'
118
+ const toolBuffers = new Map<number, { id: string; name: string; json: string }>()
119
+
120
+ try {
121
+ for await (const frame of iterSseEvents(response.body, signal, READ_TIMEOUT_MS)) {
122
+ const eventType = frame.event
123
+ let parsed: AnthropicStreamMessage
124
+ try {
125
+ parsed = JSON.parse(frame.data) as AnthropicStreamMessage
126
+ } catch {
127
+ continue
128
+ }
129
+
130
+ const type = parsed.type ?? eventType ?? ''
131
+ if (type === 'message_start') {
132
+ inputTokens = parsed.message?.usage?.input_tokens ?? inputTokens
133
+ outputTokens = parsed.message?.usage?.output_tokens ?? outputTokens
134
+ continue
135
+ }
136
+ if (type === 'content_block_start' && parsed.content_block?.type === 'tool_use') {
137
+ const id = parsed.content_block.id ?? `tool-${parsed.index ?? 0}`
138
+ const name = parsed.content_block.name ?? 'unknown'
139
+ const json = parsed.content_block.input ? JSON.stringify(parsed.content_block.input) : ''
140
+ toolBuffers.set(parsed.index ?? 0, { id, name, json })
141
+ yield { type: 'tool_use_start', id, name }
142
+ continue
143
+ }
144
+ if (type === 'content_block_delta') {
145
+ if (parsed.delta?.type === 'text_delta' && parsed.delta.text) {
146
+ yield { type: 'text', delta: parsed.delta.text }
147
+ } else if (parsed.delta?.type === 'thinking_delta' && parsed.delta.thinking) {
148
+ yield { type: 'thinking', delta: parsed.delta.thinking }
149
+ } else if (parsed.delta?.type === 'input_json_delta' && typeof parsed.delta.partial_json === 'string') {
150
+ const buffer = toolBuffers.get(parsed.index ?? 0)
151
+ if (buffer) {
152
+ buffer.json += parsed.delta.partial_json
153
+ yield { type: 'tool_use_delta', id: buffer.id, delta: parsed.delta.partial_json }
154
+ }
155
+ }
156
+ continue
157
+ }
158
+ if (type === 'content_block_stop') {
159
+ const buffer = toolBuffers.get(parsed.index ?? 0)
160
+ if (buffer) {
161
+ let input: Record<string, unknown> = {}
162
+ try {
163
+ input = buffer.json.trim() ? JSON.parse(buffer.json) as Record<string, unknown> : {}
164
+ } catch {
165
+ input = {}
166
+ }
167
+ yield { type: 'tool_use_stop', id: buffer.id, name: buffer.name, input }
168
+ toolBuffers.delete(parsed.index ?? 0)
169
+ }
170
+ continue
171
+ }
172
+ if (type === 'message_delta') {
173
+ inputTokens = parsed.usage?.input_tokens ?? inputTokens
174
+ outputTokens = parsed.usage?.output_tokens ?? outputTokens
175
+ stopReason = normalizeStopReason(parsed.delta?.stop_reason)
176
+ continue
177
+ }
178
+ if (type === 'error') {
179
+ const message = parsed.error?.message || 'anthropic stream error'
180
+ const transient = parsed.error?.type === 'overloaded_error' || parsed.error?.type === 'rate_limit_error'
181
+ throw new ProviderError(message, { transient })
182
+ }
183
+ if (type === 'message_stop') {
184
+ break
185
+ }
186
+ }
187
+ } catch (err: unknown) {
188
+ if (signal.aborted) return
189
+ yield { type: 'error', message: (err as Error).message || 'stream error' }
190
+ return
191
+ }
192
+
193
+ if (signal.aborted) return
194
+ yield { type: 'done', inputTokens, outputTokens, stopReason }
195
+ }
196
+ }
197
+
198
+ function splitMessages(messages: Message[]): {
199
+ system?: string
200
+ conversation: Array<{
201
+ role: 'user' | 'assistant'
202
+ content: Array<
203
+ | { type: 'text'; text: string }
204
+ | { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
205
+ | { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean }
206
+ >
207
+ }>
208
+ } {
209
+ const systemParts: string[] = []
210
+ const conversation: Array<{
211
+ role: 'user' | 'assistant'
212
+ content: Array<
213
+ | { type: 'text'; text: string }
214
+ | { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
215
+ | { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean }
216
+ >
217
+ }> = []
218
+
219
+ for (const message of messages) {
220
+ const blocks = normalizeBlocks(message.content)
221
+ if (blocks.length === 0) continue
222
+ if (message.role === 'system') {
223
+ const systemText = blocks.filter(block => block.type === 'text').map(block => block.text).join('\n\n').trim()
224
+ if (systemText) systemParts.push(systemText)
225
+ continue
226
+ }
227
+ conversation.push({
228
+ role: message.role,
229
+ content: blocks.map(block => {
230
+ if (block.type === 'text') return { type: 'text', text: block.text }
231
+ if (block.type === 'tool_use') return { type: 'tool_use', id: block.id, name: block.name, input: block.input }
232
+ return { type: 'tool_result', tool_use_id: block.toolUseId, content: block.content, is_error: block.isError }
233
+ }),
234
+ })
235
+ }
236
+
237
+ return {
238
+ system: systemParts.length > 0 ? systemParts.join('\n\n') : undefined,
239
+ conversation,
240
+ }
241
+ }
242
+
243
+ function normalizeBlocks(content: Message['content']): MessageContentBlock[] {
244
+ if (typeof content === 'string') {
245
+ const text = content.trim()
246
+ return text ? [{ type: 'text', text }] : []
247
+ }
248
+ return content.filter(block => {
249
+ if (block.type === 'text') return block.text.trim().length > 0
250
+ return true
251
+ })
252
+ }
253
+
254
+ function normalizeStopReason(value?: string): 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'unknown' {
255
+ if (value === 'end_turn' || value === 'tool_use' || value === 'max_tokens' || value === 'stop_sequence') {
256
+ return value
257
+ }
258
+ return 'unknown'
259
+ }
@@ -0,0 +1,62 @@
1
+ import type { ProviderId } from '../storage/config.js'
2
+ import type { RetryEvent } from '../utils/withRetry.js'
3
+
4
+ export type Role = 'system' | 'user' | 'assistant'
5
+
6
+ export type TextBlock = {
7
+ type: 'text'
8
+ text: string
9
+ }
10
+
11
+ export type ToolUseBlock = {
12
+ type: 'tool_use'
13
+ id: string
14
+ name: string
15
+ input: Record<string, unknown>
16
+ }
17
+
18
+ export type ToolResultBlock = {
19
+ type: 'tool_result'
20
+ toolUseId: string
21
+ content: string
22
+ isError?: boolean
23
+ }
24
+
25
+ export type MessageContentBlock = TextBlock | ToolUseBlock | ToolResultBlock
26
+
27
+ export type Message = {
28
+ role: Role
29
+ content: string | MessageContentBlock[]
30
+ }
31
+
32
+ export type ProviderRetryStreamEvent = { type: 'retry' } & RetryEvent
33
+
34
+ export type StreamEvent =
35
+ | { type: 'text'; delta: string }
36
+ | { type: 'thinking'; delta: string }
37
+ | ProviderRetryStreamEvent
38
+ | { type: 'tool_use_start'; id: string; name: string }
39
+ | { type: 'tool_use_delta'; id: string; delta: string }
40
+ | { type: 'tool_use_stop'; id: string; name: string; input: Record<string, unknown> }
41
+ | { type: 'done'; inputTokens?: number; outputTokens?: number; stopReason?: 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'unknown' }
42
+ | { type: 'error'; message: string }
43
+
44
+ export type ProviderCompleteOptions = {
45
+ maxTokens?: number
46
+ }
47
+
48
+ export interface Provider {
49
+ readonly id: ProviderId
50
+ readonly model: string
51
+ readonly supportsTools: boolean
52
+ complete(messages: Message[], signal: AbortSignal, options?: ProviderCompleteOptions): AsyncIterable<StreamEvent>
53
+ }
54
+
55
+ export class ProviderError extends Error {
56
+ readonly transient: boolean
57
+ constructor(message: string, options: { transient?: boolean } = {}) {
58
+ super(message)
59
+ this.name = 'ProviderError'
60
+ this.transient = options.transient ?? false
61
+ }
62
+ }
@@ -0,0 +1,62 @@
1
+ import type { ProviderId } from '../storage/config.js'
2
+ import { ProviderError } from './contracts.js'
3
+
4
+ type ErrorBody =
5
+ | string
6
+ | {
7
+ error?: {
8
+ message?: string
9
+ type?: string
10
+ }
11
+ message?: string
12
+ detail?: string
13
+ }
14
+
15
+ export async function providerErrorFromResponse(
16
+ provider: ProviderId,
17
+ response: Response,
18
+ ): Promise<ProviderError> {
19
+ const detail = await readErrorDetail(response)
20
+
21
+ if (provider !== 'llamacpp') {
22
+ if (response.status === 401 || response.status === 403) {
23
+ return new ProviderError(`auth failed: check your ${provider} key (/doctor to verify)`)
24
+ }
25
+ if (response.status === 429) {
26
+ return new ProviderError(detail || `${provider} rate limit exceeded`, { transient: true })
27
+ }
28
+ if (response.status >= 500) {
29
+ return new ProviderError(detail || `${provider} server error (${response.status})`, { transient: true })
30
+ }
31
+ }
32
+
33
+ return new ProviderError(
34
+ detail ? `HTTP ${response.status}: ${detail}` : `HTTP ${response.status}`,
35
+ { transient: response.status === 429 || response.status >= 500 },
36
+ )
37
+ }
38
+
39
+ async function readErrorDetail(response: Response): Promise<string> {
40
+ let text = ''
41
+ try {
42
+ text = (await response.text()).trim()
43
+ } catch {
44
+ return ''
45
+ }
46
+ if (!text) return ''
47
+
48
+ try {
49
+ const parsed = JSON.parse(text) as ErrorBody
50
+ const nestedMessage =
51
+ typeof parsed === 'object' && parsed !== null
52
+ ? parsed.error?.message ?? parsed.message ?? parsed.detail ?? ''
53
+ : ''
54
+ return normalizeDetail(nestedMessage || text)
55
+ } catch {
56
+ return normalizeDetail(text)
57
+ }
58
+ }
59
+
60
+ function normalizeDetail(detail: string): string {
61
+ return detail.replace(/\s+/g, ' ').trim().slice(0, 400)
62
+ }