@swarmclawai/swarmclaw 1.3.6 → 1.4.2

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 (126) hide show
  1. package/README.md +16 -52
  2. package/next.config.ts +9 -4
  3. package/package.json +18 -10
  4. package/scripts/build-bootstrap-env.mjs +24 -0
  5. package/scripts/run-next-build.mjs +74 -0
  6. package/scripts/run-next-typegen.mjs +61 -0
  7. package/src/app/api/.well-known/agent-card/route.ts +46 -0
  8. package/src/app/api/a2a/route.ts +56 -0
  9. package/src/app/api/a2a/tasks/[taskId]/status/route.ts +49 -0
  10. package/src/app/api/approvals/route.test.ts +29 -3
  11. package/src/app/api/approvals/route.ts +13 -7
  12. package/src/app/api/chats/[id]/chat/route.test.ts +64 -0
  13. package/src/app/api/chats/[id]/chat/route.ts +24 -8
  14. package/src/app/api/chats/[id]/deploy/route.ts +2 -2
  15. package/src/app/api/chats/chat-route.test.ts +68 -0
  16. package/src/app/api/connectors/[id]/doctor/route.test.ts +97 -0
  17. package/src/app/api/connectors/[id]/doctor/route.ts +26 -1
  18. package/src/app/api/connectors/connector-doctor-route.test.ts +1 -0
  19. package/src/app/api/logs/route.test.ts +61 -0
  20. package/src/app/api/logs/route.ts +35 -0
  21. package/src/app/api/openclaw/sync/route.ts +1 -1
  22. package/src/app/api/swarmfeed/channels/route.ts +14 -0
  23. package/src/app/api/swarmfeed/posts/route.ts +60 -0
  24. package/src/app/api/swarmfeed/route.ts +37 -0
  25. package/src/app/api/tts/route.test.ts +82 -0
  26. package/src/app/api/tts/route.ts +13 -6
  27. package/src/app/api/tts/stream/route.ts +12 -5
  28. package/src/app/error.tsx +32 -0
  29. package/src/app/global-error.tsx +33 -0
  30. package/src/app/protocols/builder/[templateId]/page.tsx +93 -0
  31. package/src/app/protocols/page.tsx +16 -7
  32. package/src/app/swarmfeed/page.tsx +7 -0
  33. package/src/cli/index.js +22 -0
  34. package/src/cli/spec.js +9 -0
  35. package/src/components/agents/agent-avatar.tsx +2 -5
  36. package/src/components/agents/agent-sheet.tsx +10 -0
  37. package/src/components/auth/access-key-gate.tsx +25 -0
  38. package/src/components/layout/error-boundary.tsx +12 -30
  39. package/src/components/layout/error-fallback.tsx +61 -0
  40. package/src/components/layout/sidebar-rail.tsx +52 -0
  41. package/src/components/protocols/builder/edge-editor.tsx +43 -0
  42. package/src/components/protocols/builder/edge-types/branch-edge.tsx +33 -0
  43. package/src/components/protocols/builder/edge-types/default-edge.tsx +18 -0
  44. package/src/components/protocols/builder/edge-types/index.ts +3 -0
  45. package/src/components/protocols/builder/edge-types/loop-edge.tsx +19 -0
  46. package/src/components/protocols/builder/node-inspector.tsx +227 -0
  47. package/src/components/protocols/builder/node-palette.tsx +97 -0
  48. package/src/components/protocols/builder/node-types/branch-node.tsx +34 -0
  49. package/src/components/protocols/builder/node-types/complete-node.tsx +17 -0
  50. package/src/components/protocols/builder/node-types/for-each-node.tsx +21 -0
  51. package/src/components/protocols/builder/node-types/index.ts +9 -0
  52. package/src/components/protocols/builder/node-types/join-node.tsx +18 -0
  53. package/src/components/protocols/builder/node-types/loop-node.tsx +22 -0
  54. package/src/components/protocols/builder/node-types/parallel-node.tsx +31 -0
  55. package/src/components/protocols/builder/node-types/phase-node.tsx +52 -0
  56. package/src/components/protocols/builder/node-types/subflow-node.tsx +23 -0
  57. package/src/components/protocols/builder/node-types/swarm-node.tsx +26 -0
  58. package/src/components/protocols/builder/protocol-builder-canvas.tsx +184 -0
  59. package/src/components/protocols/builder/run-overlay.tsx +29 -0
  60. package/src/components/protocols/builder/template-gallery.tsx +53 -0
  61. package/src/components/protocols/builder/validation-panel.tsx +57 -0
  62. package/src/components/skills/skills-workspace.tsx +1 -9
  63. package/src/features/protocols/builder/hooks/index.ts +2 -0
  64. package/src/features/protocols/builder/hooks/use-canvas-validation.ts +14 -0
  65. package/src/features/protocols/builder/hooks/use-run-overlay.ts +39 -0
  66. package/src/features/protocols/builder/hooks/use-template-sync.ts +45 -0
  67. package/src/features/protocols/builder/protocol-builder-store.ts +233 -0
  68. package/src/features/protocols/builder/utils/node-position-layout.ts +41 -0
  69. package/src/features/protocols/builder/utils/nodes-to-template.test.ts +179 -0
  70. package/src/features/protocols/builder/utils/nodes-to-template.ts +49 -0
  71. package/src/features/protocols/builder/utils/template-to-nodes.test.ts +314 -0
  72. package/src/features/protocols/builder/utils/template-to-nodes.ts +169 -0
  73. package/src/features/protocols/builder/validators/dag-validator.test.ts +150 -0
  74. package/src/features/protocols/builder/validators/dag-validator.ts +119 -0
  75. package/src/features/swarmfeed/agent-social-settings.tsx +277 -0
  76. package/src/features/swarmfeed/compose-post.tsx +139 -0
  77. package/src/features/swarmfeed/feed-page.tsx +136 -0
  78. package/src/features/swarmfeed/post-card.tsx +114 -0
  79. package/src/features/swarmfeed/queries.ts +28 -0
  80. package/src/lib/a2a/agent-card.ts +61 -0
  81. package/src/lib/a2a/auth.ts +54 -0
  82. package/src/lib/a2a/client.ts +133 -0
  83. package/src/lib/a2a/discovery.ts +116 -0
  84. package/src/lib/a2a/handlers.ts +176 -0
  85. package/src/lib/a2a/json-rpc-router.ts +38 -0
  86. package/src/lib/a2a/types.ts +95 -0
  87. package/src/lib/app/navigation.ts +1 -0
  88. package/src/lib/app/report-client-error.ts +52 -0
  89. package/src/lib/app/view-constants.ts +9 -1
  90. package/src/lib/providers/anthropic.ts +119 -107
  91. package/src/lib/providers/ollama.ts +34 -14
  92. package/src/lib/providers/openai.ts +154 -142
  93. package/src/lib/providers/openclaw.ts +3 -3
  94. package/src/lib/server/agents/main-agent-loop.test.ts +94 -0
  95. package/src/lib/server/agents/main-agent-loop.ts +377 -41
  96. package/src/lib/server/chat-execution/chat-execution.ts +12 -7
  97. package/src/lib/server/chat-execution/chat-turn-preparation.ts +19 -12
  98. package/src/lib/server/connectors/swarmdock.ts +1 -1
  99. package/src/lib/server/extensions.ts +11 -0
  100. package/src/lib/server/messages/message-repository.ts +31 -0
  101. package/src/lib/server/openclaw/sync.ts +4 -4
  102. package/src/lib/server/protocols/protocol-a2a-delegate.ts +135 -0
  103. package/src/lib/server/protocols/protocol-normalization.ts +1 -0
  104. package/src/lib/server/protocols/protocol-step-helpers.test.ts +1 -1
  105. package/src/lib/server/protocols/protocol-step-helpers.ts +1 -0
  106. package/src/lib/server/protocols/protocol-step-processors.ts +2 -0
  107. package/src/lib/server/protocols/protocol-types.ts +1 -0
  108. package/src/lib/server/provider-health.ts +19 -3
  109. package/src/lib/server/safe-parse-body.test.ts +32 -0
  110. package/src/lib/server/safe-parse-body.ts +20 -3
  111. package/src/lib/server/session-tools/delegate.ts +151 -77
  112. package/src/lib/server/storage-auth.ts +10 -2
  113. package/src/lib/server/storage-normalization.ts +11 -0
  114. package/src/lib/server/storage.ts +113 -4
  115. package/src/lib/server/working-state/service.test.ts +2 -3
  116. package/src/lib/server/working-state/service.ts +37 -6
  117. package/src/lib/swarmfeed-client.ts +157 -0
  118. package/src/lib/validation/schemas.ts +1 -1
  119. package/src/stores/slices/data-slice.ts +3 -0
  120. package/src/stores/use-approval-store.ts +4 -1
  121. package/src/types/agent.ts +31 -1
  122. package/src/types/index.ts +1 -0
  123. package/src/types/protocol.ts +19 -0
  124. package/src/types/session.ts +1 -1
  125. package/src/types/swarmfeed.ts +30 -0
  126. package/tsconfig.json +1 -2
@@ -0,0 +1,38 @@
1
+ import { hmrSingleton, errorMessage } from '@/lib/shared-utils'
2
+ import type { A2AMethod, A2AMethodHandler, A2AContext, JsonRpcRequest, JsonRpcResponse } from './types'
3
+ import { JSON_RPC_ERRORS } from './types'
4
+
5
+ export class JsonRpcRouter {
6
+ private handlers = new Map<string, A2AMethodHandler>()
7
+
8
+ register(method: A2AMethod | string, handler: A2AMethodHandler): void {
9
+ this.handlers.set(method, handler)
10
+ }
11
+
12
+ async route(request: JsonRpcRequest, context: A2AContext): Promise<JsonRpcResponse> {
13
+ const handler = this.handlers.get(request.method)
14
+ if (!handler) {
15
+ return {
16
+ jsonrpc: '2.0',
17
+ error: { code: JSON_RPC_ERRORS.METHOD_NOT_FOUND, message: 'Method not found', data: { method: request.method } },
18
+ id: request.id,
19
+ }
20
+ }
21
+ try {
22
+ const result = await handler(request.params ?? {}, context)
23
+ return { jsonrpc: '2.0', result, id: request.id }
24
+ } catch (err) {
25
+ return {
26
+ jsonrpc: '2.0',
27
+ error: { code: JSON_RPC_ERRORS.INTERNAL_ERROR, message: errorMessage(err) },
28
+ id: request.id,
29
+ }
30
+ }
31
+ }
32
+
33
+ listMethods(): string[] {
34
+ return [...this.handlers.keys()]
35
+ }
36
+ }
37
+
38
+ export const a2aRouter = hmrSingleton('a2a_jsonrpc_router', () => new JsonRpcRouter())
@@ -0,0 +1,95 @@
1
+ import { z } from 'zod'
2
+
3
+ // --- A2A Agent Card ---
4
+ // Ref: https://a2a-protocol.org/v0.3.0/specification/#agent-card
5
+
6
+ export const AgentCardCapabilitySchema = z.object({
7
+ name: z.string(),
8
+ methods: z.array(z.string()).optional(),
9
+ description: z.string().optional(),
10
+ })
11
+
12
+ export const AgentCardSkillSchema = z.object({
13
+ name: z.string(),
14
+ description: z.string().optional(),
15
+ parameters: z.record(z.string(), z.unknown()).optional(),
16
+ returns: z.record(z.string(), z.unknown()).optional(),
17
+ })
18
+
19
+ export const AgentCardSchema = z.object({
20
+ name: z.string(),
21
+ description: z.string(),
22
+ version: z.string(),
23
+ protocolVersion: z.string().default('0.3.0'),
24
+ apiEndpoint: z.string().url(),
25
+ capabilities: z.array(AgentCardCapabilitySchema).default([]),
26
+ skills: z.array(AgentCardSkillSchema).default([]),
27
+ authMethods: z.array(z.enum(['api_key', 'ed25519', 'oauth2'])).default(['api_key']),
28
+ supportsStreaming: z.boolean().default(true),
29
+ supportsAsync: z.boolean().default(true),
30
+ rateLimit: z.object({
31
+ requestsPerMinute: z.number().optional(),
32
+ maxConcurrentRequests: z.number().optional(),
33
+ }).optional(),
34
+ extensions: z.array(z.object({
35
+ name: z.string(),
36
+ version: z.string(),
37
+ url: z.string().url().optional(),
38
+ })).default([]),
39
+ tags: z.array(z.string()).default([]),
40
+ icon: z.string().url().optional(),
41
+ website: z.string().url().optional(),
42
+ })
43
+
44
+ export type AgentCard = z.infer<typeof AgentCardSchema>
45
+
46
+ // --- JSON-RPC 2.0 ---
47
+ // Ref: https://www.jsonrpc.org/specification
48
+
49
+ export const JsonRpcRequestSchema = z.object({
50
+ jsonrpc: z.literal('2.0'),
51
+ method: z.string(),
52
+ params: z.record(z.string(), z.unknown()).optional(),
53
+ id: z.union([z.string(), z.number()]).optional(),
54
+ })
55
+
56
+ export type JsonRpcRequest = z.infer<typeof JsonRpcRequestSchema>
57
+
58
+ export interface JsonRpcResponse<T = unknown> {
59
+ jsonrpc: '2.0'
60
+ result?: T
61
+ error?: { code: number; message: string; data?: unknown }
62
+ id?: string | number
63
+ }
64
+
65
+ // --- A2A Method Types ---
66
+
67
+ export type A2AMethod = 'executeTask' | 'getStatus' | 'cancelTask' | 'discoverAgents'
68
+
69
+ export type A2AMethodHandler = (params: Record<string, unknown>, context: A2AContext) => Promise<unknown>
70
+
71
+ export interface A2AContext {
72
+ agentId: string
73
+ requesterId: string
74
+ timestamp: Date
75
+ }
76
+
77
+ export type A2ATaskStatus = 'submitted' | 'working' | 'completed' | 'failed' | 'cancelled'
78
+
79
+ // --- A2A Client Options ---
80
+
81
+ export interface A2AClientOptions {
82
+ timeout?: number
83
+ credentialId?: string | null
84
+ retryAttempts?: number
85
+ }
86
+
87
+ // --- JSON-RPC Error Codes ---
88
+
89
+ export const JSON_RPC_ERRORS = {
90
+ PARSE_ERROR: -32700,
91
+ METHOD_NOT_FOUND: -32601,
92
+ INVALID_PARAMS: -32602,
93
+ INTERNAL_ERROR: -32603,
94
+ AUTH_FAILED: -32000,
95
+ } as const
@@ -31,6 +31,7 @@ const VIEW_TO_PATH: Record<AppView, string> = {
31
31
  settings: '/settings',
32
32
  projects: '/projects',
33
33
  activity: '/activity',
34
+ swarmfeed: '/swarmfeed',
34
35
  }
35
36
 
36
37
  /** Build a URL path for a given view, optionally with an entity ID. */
@@ -0,0 +1,52 @@
1
+ 'use client'
2
+
3
+ type ReportClientErrorInput = {
4
+ source: string
5
+ error: Error | string | unknown
6
+ componentStack?: string | null
7
+ digest?: string | null
8
+ }
9
+
10
+ const reportedClientErrors = new Set<string>()
11
+
12
+ function truncate(value: string | null | undefined, max: number): string | undefined {
13
+ if (!value) return undefined
14
+ return value.length > max ? value.slice(0, max) : value
15
+ }
16
+
17
+ export function reportClientError(input: ReportClientErrorInput) {
18
+ if (typeof window === 'undefined') return
19
+
20
+ const message = input.error instanceof Error
21
+ ? input.error.message
22
+ : typeof input.error === 'string'
23
+ ? input.error
24
+ : String(input.error)
25
+
26
+ const stack = input.error instanceof Error ? input.error.stack : undefined
27
+ const fingerprint = [
28
+ input.source,
29
+ message,
30
+ input.digest || '',
31
+ input.componentStack || '',
32
+ ].join('|')
33
+
34
+ if (reportedClientErrors.has(fingerprint)) return
35
+ reportedClientErrors.add(fingerprint)
36
+
37
+ void fetch('/api/logs', {
38
+ method: 'POST',
39
+ headers: { 'content-type': 'application/json' },
40
+ body: JSON.stringify({
41
+ source: input.source,
42
+ message: truncate(message, 1000),
43
+ stack: truncate(stack, 8000),
44
+ componentStack: truncate(input.componentStack, 8000),
45
+ digest: truncate(input.digest, 200),
46
+ url: truncate(window.location.href, 2000),
47
+ pathname: truncate(window.location.pathname, 1000),
48
+ userAgent: truncate(window.navigator.userAgent, 1000),
49
+ }),
50
+ keepalive: true,
51
+ }).catch(() => {})
52
+ }
@@ -27,6 +27,7 @@ export const VIEW_LABELS: Record<AppView, string> = {
27
27
  settings: 'Settings',
28
28
  projects: 'Projects',
29
29
  activity: 'Activity',
30
+ swarmfeed: 'Feed',
30
31
  }
31
32
 
32
33
  export const CREATE_LABELS: Partial<Record<AppView, string>> = {
@@ -71,6 +72,7 @@ export const VIEW_DESCRIPTIONS: Record<AppView, string> = {
71
72
  settings: 'Manage defaults, providers, secrets, and automation settings',
72
73
  projects: 'Group agents, tasks & schedules into projects',
73
74
  activity: 'Audit trail of all entity mutations',
75
+ swarmfeed: 'Social feed for AI agents to post, follow, and engage',
74
76
  }
75
77
 
76
78
  export const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents' | 'home'>, { icon: string; title: string; description: string; features: string[] }> = {
@@ -213,10 +215,16 @@ export const VIEW_EMPTY_STATES: Record<Exclude<AppView, 'agents' | 'home'>, { ic
213
215
  description: 'Audit trail of all entity mutations across the system.',
214
216
  features: ['Track agent, task, and connector changes', 'Filter by entity type and action', 'Real-time updates via WebSocket', 'Relative timestamps'],
215
217
  },
218
+ swarmfeed: {
219
+ icon: 'rss',
220
+ title: 'Feed',
221
+ description: 'A social feed where your AI agents post updates, follow each other, and engage with content.',
222
+ features: ['Agents post status updates and insights', 'Follow agents and browse trending content', 'Channel-based topic organization', 'Like, repost, and reply interactions'],
223
+ },
216
224
  }
217
225
 
218
226
  export const FULL_WIDTH_VIEWS = new Set<AppView>([
219
227
  'home', 'org_chart', 'inbox', 'chatrooms', 'protocols', 'schedules', 'secrets', 'wallets', 'providers', 'skills',
220
228
  'connectors', 'webhooks', 'mcp_servers', 'knowledge', 'extensions',
221
- 'usage', 'runs', 'autonomy', 'logs', 'settings', 'activity', 'projects',
229
+ 'usage', 'runs', 'autonomy', 'logs', 'settings', 'activity', 'projects', 'swarmfeed',
222
230
  ])
@@ -26,119 +26,131 @@ async function fileToContentBlocks(filePath: string): Promise<Array<Record<strin
26
26
  }
27
27
 
28
28
  export function streamAnthropicChat({ session, message, imagePath, apiKey, systemPrompt, write, active, loadHistory, onUsage, signal }: StreamChatOptions): Promise<string> {
29
- return new Promise(async (resolve, reject) => {
30
- const messages = await buildMessages(session, message, imagePath, loadHistory)
31
- const model = session.model || 'claude-sonnet-4-6'
32
- let usageInput = 0
33
- let usageOutput = 0
34
-
35
- const body: Record<string, unknown> = {
36
- model,
37
- max_tokens: ANTHROPIC_MAX_TOKENS,
38
- messages,
39
- stream: true,
40
- }
41
- if (systemPrompt) {
42
- body.system = systemPrompt
43
- }
29
+ return new Promise((resolve, reject) => {
30
+ ;(async () => {
31
+ try {
32
+ const messages = await buildMessages(session, message, imagePath, loadHistory)
33
+ const model = session.model || 'claude-sonnet-4-6'
34
+ let usageInput = 0
35
+ let usageOutput = 0
36
+
37
+ const body: Record<string, unknown> = {
38
+ model,
39
+ max_tokens: ANTHROPIC_MAX_TOKENS,
40
+ messages,
41
+ stream: true,
42
+ }
43
+ if (systemPrompt) {
44
+ body.system = systemPrompt
45
+ }
44
46
 
45
- const payload = JSON.stringify(body)
46
- const abortController = { aborted: false }
47
- let fullResponse = ''
48
- let apiReqRef: ReturnType<typeof https.request> | null = null
47
+ const payload = JSON.stringify(body)
48
+ const abortController = { aborted: false }
49
+ let fullResponse = ''
50
+ let apiReqRef: ReturnType<typeof https.request> | null = null
51
+
52
+ if (signal) {
53
+ if (signal.aborted) {
54
+ abortController.aborted = true
55
+ } else {
56
+ signal.addEventListener('abort', () => {
57
+ abortController.aborted = true
58
+ apiReqRef?.destroy()
59
+ }, { once: true })
60
+ }
61
+ }
49
62
 
50
- if (signal) {
51
- if (signal.aborted) {
52
- abortController.aborted = true
53
- } else {
54
- signal.addEventListener('abort', () => {
55
- abortController.aborted = true
56
- apiReqRef?.destroy()
57
- }, { once: true })
58
- }
59
- }
63
+ const apiReq = https.request({
64
+ hostname: PROVIDER_DEFAULTS.anthropic,
65
+ path: '/v1/messages',
66
+ method: 'POST',
67
+ timeout: 60_000,
68
+ headers: {
69
+ 'x-api-key': apiKey || '',
70
+ 'anthropic-version': '2023-06-01',
71
+ 'Content-Type': 'application/json',
72
+ },
73
+ }, (apiRes) => {
74
+ if (apiRes.statusCode !== 200) {
75
+ let errBody = ''
76
+ apiRes.on('data', (c: Buffer) => errBody += c)
77
+ apiRes.on('end', () => {
78
+ const msg = `Anthropic error ${apiRes.statusCode}: ${errBody.slice(0, 200)}`
79
+ log.error(TAG, `[${session.id}] ${msg}`)
80
+ let errMsg = `Anthropic API error (${apiRes.statusCode})`
81
+ try {
82
+ const parsed = JSON.parse(errBody)
83
+ if (parsed.error?.message) errMsg = parsed.error.message
84
+ } catch {}
85
+ writeSSE(write, 'err', errMsg)
86
+ active.delete(session.id)
87
+ reject(new Error(msg))
88
+ })
89
+ return
90
+ }
91
+
92
+ let buf = ''
93
+ let malformedChunkLogged = false
94
+ apiRes.on('data', (chunk: Buffer) => {
95
+ if (abortController.aborted) return
96
+ buf += chunk.toString()
97
+ const lines = buf.split('\n')
98
+ buf = lines.pop()!
99
+
100
+ for (const line of lines) {
101
+ if (!line.startsWith('data: ')) continue
102
+ const data = line.slice(6).trim()
103
+ if (!data) continue
104
+ try {
105
+ const parsed = JSON.parse(data)
106
+ if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
107
+ fullResponse += parsed.delta.text
108
+ writeSSE(write, 'd', parsed.delta.text)
109
+ }
110
+ if (parsed.type === 'message_start' && parsed.message?.usage) {
111
+ usageInput = parsed.message.usage.input_tokens || 0
112
+ }
113
+ if (parsed.type === 'message_delta' && parsed.usage) {
114
+ usageOutput = parsed.usage.output_tokens || 0
115
+ }
116
+ } catch {
117
+ if (!malformedChunkLogged) {
118
+ malformedChunkLogged = true
119
+ log.warn(TAG, `[${session.id}] failed to parse Anthropic stream chunk`, {
120
+ sample: data.slice(0, 200),
121
+ })
122
+ }
123
+ }
124
+ }
125
+ })
60
126
 
61
- const apiReq = https.request({
62
- hostname: PROVIDER_DEFAULTS.anthropic,
63
- path: '/v1/messages',
64
- method: 'POST',
65
- timeout: 60_000,
66
- headers: {
67
- 'x-api-key': apiKey || '',
68
- 'anthropic-version': '2023-06-01',
69
- 'Content-Type': 'application/json',
70
- },
71
- }, (apiRes) => {
72
- if (apiRes.statusCode !== 200) {
73
- let errBody = ''
74
- apiRes.on('data', (c: Buffer) => errBody += c)
75
- apiRes.on('end', () => {
76
- const msg = `Anthropic error ${apiRes.statusCode}: ${errBody.slice(0, 200)}`
77
- log.error(TAG, `[${session.id}] ${msg}`)
78
- let errMsg = `Anthropic API error (${apiRes.statusCode})`
79
- try {
80
- const parsed = JSON.parse(errBody)
81
- if (parsed.error?.message) errMsg = parsed.error.message
82
- } catch {}
83
- writeSSE(write, 'err', errMsg)
84
- active.delete(session.id)
85
- reject(new Error(msg))
127
+ apiRes.on('end', () => {
128
+ if (onUsage && (usageInput > 0 || usageOutput > 0)) {
129
+ onUsage({ inputTokens: usageInput, outputTokens: usageOutput })
130
+ }
131
+ active.delete(session.id)
132
+ resolve(fullResponse)
133
+ })
86
134
  })
87
- return
88
- }
89
135
 
90
- let buf = ''
91
- apiRes.on('data', (chunk: Buffer) => {
92
- if (abortController.aborted) return
93
- buf += chunk.toString()
94
- const lines = buf.split('\n')
95
- buf = lines.pop()!
96
-
97
- for (const line of lines) {
98
- if (!line.startsWith('data: ')) continue
99
- const data = line.slice(6).trim()
100
- if (!data) continue
101
- try {
102
- const parsed = JSON.parse(data)
103
- if (parsed.type === 'content_block_delta' && parsed.delta?.text) {
104
- fullResponse += parsed.delta.text
105
- writeSSE(write, 'd', parsed.delta.text)
106
- }
107
- if (parsed.type === 'message_start' && parsed.message?.usage) {
108
- usageInput = parsed.message.usage.input_tokens || 0
109
- }
110
- if (parsed.type === 'message_delta' && parsed.usage) {
111
- usageOutput = parsed.usage.output_tokens || 0
112
- }
113
- } catch {}
114
- }
115
- })
136
+ apiReqRef = apiReq
137
+ active.set(session.id, { kill: () => { abortController.aborted = true; apiReq.destroy() } })
116
138
 
117
- apiRes.on('end', () => {
118
- if (onUsage && (usageInput > 0 || usageOutput > 0)) {
119
- onUsage({ inputTokens: usageInput, outputTokens: usageOutput })
120
- }
121
- active.delete(session.id)
122
- resolve(fullResponse)
123
- })
124
- })
125
-
126
- apiReqRef = apiReq
127
- active.set(session.id, { kill: () => { abortController.aborted = true; apiReq.destroy() } })
128
-
129
- apiReq.on('timeout', () => {
130
- log.error(TAG, `[${session.id}] anthropic request timed out after 60s`)
131
- apiReq.destroy(new Error('Request timed out after 60s'))
132
- })
133
-
134
- apiReq.on('error', (e) => {
135
- log.error(TAG, `[${session.id}] anthropic request error:`, e.message)
136
- writeSSE(write, 'err', e.message)
137
- active.delete(session.id)
138
- reject(e)
139
- })
140
-
141
- apiReq.end(payload)
139
+ apiReq.on('timeout', () => {
140
+ log.error(TAG, `[${session.id}] anthropic request timed out after 60s`)
141
+ apiReq.destroy(new Error('Request timed out after 60s'))
142
+ })
143
+
144
+ apiReq.on('error', (e) => {
145
+ log.error(TAG, `[${session.id}] anthropic request error:`, e.message)
146
+ writeSSE(write, 'err', e.message)
147
+ active.delete(session.id)
148
+ reject(e)
149
+ })
150
+
151
+ apiReq.end(payload)
152
+ } catch (err) { reject(err) }
153
+ })()
142
154
  })
143
155
  }
144
156
 
@@ -2,6 +2,7 @@ import fs from 'fs'
2
2
  import http from 'http'
3
3
  import https from 'https'
4
4
  import type { StreamChatOptions } from './index'
5
+ import { streamOpenAiChat } from './openai'
5
6
  import { IMAGE_EXTS, TEXT_EXTS, MAX_HISTORY_MESSAGES, writeSSE } from './provider-defaults'
6
7
  import { log } from '@/lib/server/logger'
7
8
  import { resolveOllamaRuntimeConfig } from '@/lib/server/ollama-runtime'
@@ -9,23 +10,34 @@ import { resolveImagePath } from '@/lib/server/resolve-image'
9
10
 
10
11
  const TAG = 'provider-ollama'
11
12
 
12
- export function streamOllamaChat({ session, message, imagePath, apiKey, write, active, loadHistory, onUsage, signal }: StreamChatOptions): Promise<string> {
13
+ /** Ollama Cloud uses the OpenAI-compatible /v1 endpoint, not the native /api/chat protocol. */
14
+ const OLLAMA_CLOUD_OPENAI_ENDPOINT = 'https://ollama.com/v1'
15
+
16
+ export function streamOllamaChat(opts: StreamChatOptions): Promise<string> {
17
+ const { session, apiKey, write, active } = opts
18
+ const runtime = resolveOllamaRuntimeConfig({
19
+ model: session.model,
20
+ ollamaMode: session.ollamaMode,
21
+ apiKey,
22
+ apiEndpoint: session.apiEndpoint,
23
+ })
24
+
25
+ if (runtime.useCloud) {
26
+ if (!runtime.apiKey) {
27
+ writeSSE(write, 'err', 'Ollama Cloud model requires an API key. Set OLLAMA_API_KEY or attach an Ollama credential.')
28
+ active.delete(session.id)
29
+ return Promise.resolve('')
30
+ }
31
+ // Delegate to OpenAI-compatible handler with the cloud endpoint
32
+ const cloudSession = { ...session, model: runtime.model || 'llama3', apiEndpoint: OLLAMA_CLOUD_OPENAI_ENDPOINT }
33
+ return streamOpenAiChat({ ...opts, session: cloudSession, apiKey: runtime.apiKey })
34
+ }
35
+
36
+ const { message, imagePath, loadHistory, onUsage, signal } = opts
13
37
  return new Promise((resolve, reject) => {
14
38
  const messages = buildMessages(session, message, imagePath, loadHistory)
15
- const runtime = resolveOllamaRuntimeConfig({
16
- model: session.model,
17
- ollamaMode: session.ollamaMode,
18
- apiKey,
19
- apiEndpoint: session.apiEndpoint,
20
- })
21
39
  const model = runtime.model || 'llama3'
22
40
  const endpoint = runtime.endpoint
23
- if (runtime.useCloud && !runtime.apiKey) {
24
- writeSSE(write, 'err', 'Ollama Cloud model requires an API key. Set OLLAMA_API_KEY or attach an Ollama credential.')
25
- active.delete(session.id)
26
- resolve('')
27
- return
28
- }
29
41
 
30
42
  const parsed = new URL(endpoint)
31
43
  const isHttps = parsed.protocol === 'https:'
@@ -81,6 +93,7 @@ export function streamOllamaChat({ session, message, imagePath, apiKey, write, a
81
93
  }
82
94
 
83
95
  let buf = ''
96
+ let malformedChunkLogged = false
84
97
  apiRes.on('data', (chunk: Buffer) => {
85
98
  if (abortController.aborted) return
86
99
  buf += chunk.toString()
@@ -103,7 +116,14 @@ export function streamOllamaChat({ session, message, imagePath, apiKey, write, a
103
116
  onUsage({ inputTokens: input, outputTokens: output })
104
117
  }
105
118
  }
106
- } catch {}
119
+ } catch {
120
+ if (!malformedChunkLogged) {
121
+ malformedChunkLogged = true
122
+ log.warn(TAG, `[${session.id}] failed to parse Ollama stream chunk`, {
123
+ sample: line.slice(0, 200),
124
+ })
125
+ }
126
+ }
107
127
  }
108
128
  })
109
129