fixo-cli 1.0.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.
Files changed (303) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +530 -0
  3. package/dist/agent/agent-client.d.ts +108 -0
  4. package/dist/agent/agent-client.d.ts.map +1 -0
  5. package/dist/agent/agent-client.js +1247 -0
  6. package/dist/agent/agent-client.js.map +1 -0
  7. package/dist/agent/agent-pool.d.ts +20 -0
  8. package/dist/agent/agent-pool.d.ts.map +1 -0
  9. package/dist/agent/agent-pool.js +217 -0
  10. package/dist/agent/agent-pool.js.map +1 -0
  11. package/dist/agent/background-awareness.d.ts +55 -0
  12. package/dist/agent/background-awareness.d.ts.map +1 -0
  13. package/dist/agent/background-awareness.js +104 -0
  14. package/dist/agent/background-awareness.js.map +1 -0
  15. package/dist/agent/command-parser.d.ts +33 -0
  16. package/dist/agent/command-parser.d.ts.map +1 -0
  17. package/dist/agent/command-parser.js +120 -0
  18. package/dist/agent/command-parser.js.map +1 -0
  19. package/dist/agent/context-budget.d.ts +91 -0
  20. package/dist/agent/context-budget.d.ts.map +1 -0
  21. package/dist/agent/context-budget.js +219 -0
  22. package/dist/agent/context-budget.js.map +1 -0
  23. package/dist/agent/conversation.d.ts +190 -0
  24. package/dist/agent/conversation.d.ts.map +1 -0
  25. package/dist/agent/conversation.js +547 -0
  26. package/dist/agent/conversation.js.map +1 -0
  27. package/dist/agent/hooks.d.ts +72 -0
  28. package/dist/agent/hooks.d.ts.map +1 -0
  29. package/dist/agent/hooks.js +214 -0
  30. package/dist/agent/hooks.js.map +1 -0
  31. package/dist/agent/mcp-bridge.d.ts +13 -0
  32. package/dist/agent/mcp-bridge.d.ts.map +1 -0
  33. package/dist/agent/mcp-bridge.js +86 -0
  34. package/dist/agent/mcp-bridge.js.map +1 -0
  35. package/dist/agent/mcp-client.d.ts +24 -0
  36. package/dist/agent/mcp-client.d.ts.map +1 -0
  37. package/dist/agent/mcp-client.js +146 -0
  38. package/dist/agent/mcp-client.js.map +1 -0
  39. package/dist/agent/mcp-manager.d.ts +13 -0
  40. package/dist/agent/mcp-manager.d.ts.map +1 -0
  41. package/dist/agent/mcp-manager.js +84 -0
  42. package/dist/agent/mcp-manager.js.map +1 -0
  43. package/dist/agent/mcp-registry.d.ts +45 -0
  44. package/dist/agent/mcp-registry.d.ts.map +1 -0
  45. package/dist/agent/mcp-registry.js +98 -0
  46. package/dist/agent/mcp-registry.js.map +1 -0
  47. package/dist/agent/orchestrator.d.ts +14 -0
  48. package/dist/agent/orchestrator.d.ts.map +1 -0
  49. package/dist/agent/orchestrator.js +118 -0
  50. package/dist/agent/orchestrator.js.map +1 -0
  51. package/dist/agent/parser-adapter.d.ts +120 -0
  52. package/dist/agent/parser-adapter.d.ts.map +1 -0
  53. package/dist/agent/parser-adapter.js +265 -0
  54. package/dist/agent/parser-adapter.js.map +1 -0
  55. package/dist/agent/parsers/imports.d.ts +11 -0
  56. package/dist/agent/parsers/imports.d.ts.map +1 -0
  57. package/dist/agent/parsers/imports.js +94 -0
  58. package/dist/agent/parsers/imports.js.map +1 -0
  59. package/dist/agent/parsers/shell.d.ts +23 -0
  60. package/dist/agent/parsers/shell.d.ts.map +1 -0
  61. package/dist/agent/parsers/shell.js +200 -0
  62. package/dist/agent/parsers/shell.js.map +1 -0
  63. package/dist/agent/parsers/symbols.d.ts +17 -0
  64. package/dist/agent/parsers/symbols.d.ts.map +1 -0
  65. package/dist/agent/parsers/symbols.js +103 -0
  66. package/dist/agent/parsers/symbols.js.map +1 -0
  67. package/dist/agent/permissions.d.ts +65 -0
  68. package/dist/agent/permissions.d.ts.map +1 -0
  69. package/dist/agent/permissions.js +219 -0
  70. package/dist/agent/permissions.js.map +1 -0
  71. package/dist/agent/predictive-gate.d.ts +69 -0
  72. package/dist/agent/predictive-gate.d.ts.map +1 -0
  73. package/dist/agent/predictive-gate.js +128 -0
  74. package/dist/agent/predictive-gate.js.map +1 -0
  75. package/dist/agent/provider-cooldown.d.ts +144 -0
  76. package/dist/agent/provider-cooldown.d.ts.map +1 -0
  77. package/dist/agent/provider-cooldown.js +300 -0
  78. package/dist/agent/provider-cooldown.js.map +1 -0
  79. package/dist/agent/providers-manager.d.ts +109 -0
  80. package/dist/agent/providers-manager.d.ts.map +1 -0
  81. package/dist/agent/providers-manager.js +464 -0
  82. package/dist/agent/providers-manager.js.map +1 -0
  83. package/dist/agent/repo-map.d.ts +6 -0
  84. package/dist/agent/repo-map.d.ts.map +1 -0
  85. package/dist/agent/repo-map.js +221 -0
  86. package/dist/agent/repo-map.js.map +1 -0
  87. package/dist/agent/retry.d.ts +103 -0
  88. package/dist/agent/retry.d.ts.map +1 -0
  89. package/dist/agent/retry.js +276 -0
  90. package/dist/agent/retry.js.map +1 -0
  91. package/dist/agent/search/index.d.ts +61 -0
  92. package/dist/agent/search/index.d.ts.map +1 -0
  93. package/dist/agent/search/index.js +314 -0
  94. package/dist/agent/search/index.js.map +1 -0
  95. package/dist/agent/single-agent.d.ts +76 -0
  96. package/dist/agent/single-agent.d.ts.map +1 -0
  97. package/dist/agent/single-agent.js +697 -0
  98. package/dist/agent/single-agent.js.map +1 -0
  99. package/dist/agent/skills.d.ts +22 -0
  100. package/dist/agent/skills.d.ts.map +1 -0
  101. package/dist/agent/skills.js +139 -0
  102. package/dist/agent/skills.js.map +1 -0
  103. package/dist/agent/stream-glue.d.ts +85 -0
  104. package/dist/agent/stream-glue.d.ts.map +1 -0
  105. package/dist/agent/stream-glue.js +120 -0
  106. package/dist/agent/stream-glue.js.map +1 -0
  107. package/dist/agent/subagent.d.ts +72 -0
  108. package/dist/agent/subagent.d.ts.map +1 -0
  109. package/dist/agent/subagent.js +193 -0
  110. package/dist/agent/subagent.js.map +1 -0
  111. package/dist/agent/telemetry.d.ts +192 -0
  112. package/dist/agent/telemetry.d.ts.map +1 -0
  113. package/dist/agent/telemetry.js +400 -0
  114. package/dist/agent/telemetry.js.map +1 -0
  115. package/dist/agent/tokenizer.d.ts +42 -0
  116. package/dist/agent/tokenizer.d.ts.map +1 -0
  117. package/dist/agent/tokenizer.js +107 -0
  118. package/dist/agent/tokenizer.js.map +1 -0
  119. package/dist/agent/tool-executor.d.ts +289 -0
  120. package/dist/agent/tool-executor.d.ts.map +1 -0
  121. package/dist/agent/tool-executor.js +2519 -0
  122. package/dist/agent/tool-executor.js.map +1 -0
  123. package/dist/agent/web-impl.d.ts +2 -0
  124. package/dist/agent/web-impl.d.ts.map +1 -0
  125. package/dist/agent/web-impl.js +34 -0
  126. package/dist/agent/web-impl.js.map +1 -0
  127. package/dist/agent/web.d.ts +8 -0
  128. package/dist/agent/web.d.ts.map +1 -0
  129. package/dist/agent/web.js +8 -0
  130. package/dist/agent/web.js.map +1 -0
  131. package/dist/agent/worker-agent.d.ts +27 -0
  132. package/dist/agent/worker-agent.d.ts.map +1 -0
  133. package/dist/agent/worker-agent.js +503 -0
  134. package/dist/agent/worker-agent.js.map +1 -0
  135. package/dist/config.d.ts +162 -0
  136. package/dist/config.d.ts.map +1 -0
  137. package/dist/config.js +138 -0
  138. package/dist/config.js.map +1 -0
  139. package/dist/context/fixo-md-watcher.d.ts +42 -0
  140. package/dist/context/fixo-md-watcher.d.ts.map +1 -0
  141. package/dist/context/fixo-md-watcher.js +126 -0
  142. package/dist/context/fixo-md-watcher.js.map +1 -0
  143. package/dist/context/fixo-md.d.ts +50 -0
  144. package/dist/context/fixo-md.d.ts.map +1 -0
  145. package/dist/context/fixo-md.js +118 -0
  146. package/dist/context/fixo-md.js.map +1 -0
  147. package/dist/context/todo.d.ts +65 -0
  148. package/dist/context/todo.d.ts.map +1 -0
  149. package/dist/context/todo.js +194 -0
  150. package/dist/context/todo.js.map +1 -0
  151. package/dist/git/git-manager.d.ts +33 -0
  152. package/dist/git/git-manager.d.ts.map +1 -0
  153. package/dist/git/git-manager.js +293 -0
  154. package/dist/git/git-manager.js.map +1 -0
  155. package/dist/git/git-ops.d.ts +10 -0
  156. package/dist/git/git-ops.d.ts.map +1 -0
  157. package/dist/git/git-ops.js +131 -0
  158. package/dist/git/git-ops.js.map +1 -0
  159. package/dist/index.d.ts +3 -0
  160. package/dist/index.d.ts.map +1 -0
  161. package/dist/index.js +352 -0
  162. package/dist/index.js.map +1 -0
  163. package/dist/indexer.d.ts +30 -0
  164. package/dist/indexer.d.ts.map +1 -0
  165. package/dist/indexer.js +273 -0
  166. package/dist/indexer.js.map +1 -0
  167. package/dist/lsp/lsp-client.d.ts +24 -0
  168. package/dist/lsp/lsp-client.d.ts.map +1 -0
  169. package/dist/lsp/lsp-client.js +205 -0
  170. package/dist/lsp/lsp-client.js.map +1 -0
  171. package/dist/lsp/lsp-manager.d.ts +17 -0
  172. package/dist/lsp/lsp-manager.d.ts.map +1 -0
  173. package/dist/lsp/lsp-manager.js +154 -0
  174. package/dist/lsp/lsp-manager.js.map +1 -0
  175. package/dist/lsp/lsp-pre-save.d.ts +137 -0
  176. package/dist/lsp/lsp-pre-save.d.ts.map +1 -0
  177. package/dist/lsp/lsp-pre-save.js +245 -0
  178. package/dist/lsp/lsp-pre-save.js.map +1 -0
  179. package/dist/lsp/syntax-fallback.d.ts +83 -0
  180. package/dist/lsp/syntax-fallback.d.ts.map +1 -0
  181. package/dist/lsp/syntax-fallback.js +275 -0
  182. package/dist/lsp/syntax-fallback.js.map +1 -0
  183. package/dist/model-outcomes.d.ts +12 -0
  184. package/dist/model-outcomes.d.ts.map +1 -0
  185. package/dist/model-outcomes.js +46 -0
  186. package/dist/model-outcomes.js.map +1 -0
  187. package/dist/planner.d.ts +32 -0
  188. package/dist/planner.d.ts.map +1 -0
  189. package/dist/planner.js +163 -0
  190. package/dist/planner.js.map +1 -0
  191. package/dist/project-memory.d.ts +29 -0
  192. package/dist/project-memory.d.ts.map +1 -0
  193. package/dist/project-memory.js +349 -0
  194. package/dist/project-memory.js.map +1 -0
  195. package/dist/review.d.ts +2 -0
  196. package/dist/review.d.ts.map +1 -0
  197. package/dist/review.js +61 -0
  198. package/dist/review.js.map +1 -0
  199. package/dist/runtime/background-jobs.d.ts +97 -0
  200. package/dist/runtime/background-jobs.d.ts.map +1 -0
  201. package/dist/runtime/background-jobs.js +331 -0
  202. package/dist/runtime/background-jobs.js.map +1 -0
  203. package/dist/runtime/credential-vault.d.ts +124 -0
  204. package/dist/runtime/credential-vault.d.ts.map +1 -0
  205. package/dist/runtime/credential-vault.js +184 -0
  206. package/dist/runtime/credential-vault.js.map +1 -0
  207. package/dist/runtime/loop-trap.d.ts +197 -0
  208. package/dist/runtime/loop-trap.d.ts.map +1 -0
  209. package/dist/runtime/loop-trap.js +420 -0
  210. package/dist/runtime/loop-trap.js.map +1 -0
  211. package/dist/runtime/policy.d.ts +15 -0
  212. package/dist/runtime/policy.d.ts.map +1 -0
  213. package/dist/runtime/policy.js +60 -0
  214. package/dist/runtime/policy.js.map +1 -0
  215. package/dist/runtime/redaction.d.ts +66 -0
  216. package/dist/runtime/redaction.d.ts.map +1 -0
  217. package/dist/runtime/redaction.js +155 -0
  218. package/dist/runtime/redaction.js.map +1 -0
  219. package/dist/runtime/session-snapshots.d.ts +76 -0
  220. package/dist/runtime/session-snapshots.d.ts.map +1 -0
  221. package/dist/runtime/session-snapshots.js +166 -0
  222. package/dist/runtime/session-snapshots.js.map +1 -0
  223. package/dist/runtime/staging.d.ts +205 -0
  224. package/dist/runtime/staging.d.ts.map +1 -0
  225. package/dist/runtime/staging.js +526 -0
  226. package/dist/runtime/staging.js.map +1 -0
  227. package/dist/runtime/task-session.d.ts +95 -0
  228. package/dist/runtime/task-session.d.ts.map +1 -0
  229. package/dist/runtime/task-session.js +263 -0
  230. package/dist/runtime/task-session.js.map +1 -0
  231. package/dist/runtime/worktree.d.ts +55 -0
  232. package/dist/runtime/worktree.d.ts.map +1 -0
  233. package/dist/runtime/worktree.js +175 -0
  234. package/dist/runtime/worktree.js.map +1 -0
  235. package/dist/setup-wizard.d.ts +8 -0
  236. package/dist/setup-wizard.d.ts.map +1 -0
  237. package/dist/setup-wizard.js +73 -0
  238. package/dist/setup-wizard.js.map +1 -0
  239. package/dist/shared/content.d.ts +43 -0
  240. package/dist/shared/content.d.ts.map +1 -0
  241. package/dist/shared/content.js +61 -0
  242. package/dist/shared/content.js.map +1 -0
  243. package/dist/shared/types.d.ts +217 -0
  244. package/dist/shared/types.d.ts.map +1 -0
  245. package/dist/shared/types.js +3 -0
  246. package/dist/shared/types.js.map +1 -0
  247. package/dist/test-runner.d.ts +5 -0
  248. package/dist/test-runner.d.ts.map +1 -0
  249. package/dist/test-runner.js +42 -0
  250. package/dist/test-runner.js.map +1 -0
  251. package/dist/types.d.ts +85 -0
  252. package/dist/types.d.ts.map +1 -0
  253. package/dist/types.js +2 -0
  254. package/dist/types.js.map +1 -0
  255. package/dist/ui/ascii.d.ts +23 -0
  256. package/dist/ui/ascii.d.ts.map +1 -0
  257. package/dist/ui/ascii.js +45 -0
  258. package/dist/ui/ascii.js.map +1 -0
  259. package/dist/ui/colors.d.ts +111 -0
  260. package/dist/ui/colors.d.ts.map +1 -0
  261. package/dist/ui/colors.js +166 -0
  262. package/dist/ui/colors.js.map +1 -0
  263. package/dist/ui/image-attach.d.ts +27 -0
  264. package/dist/ui/image-attach.d.ts.map +1 -0
  265. package/dist/ui/image-attach.js +100 -0
  266. package/dist/ui/image-attach.js.map +1 -0
  267. package/dist/ui/index.d.ts +18 -0
  268. package/dist/ui/index.d.ts.map +1 -0
  269. package/dist/ui/index.js +18 -0
  270. package/dist/ui/index.js.map +1 -0
  271. package/dist/ui/markdown-stream.d.ts +91 -0
  272. package/dist/ui/markdown-stream.d.ts.map +1 -0
  273. package/dist/ui/markdown-stream.js +524 -0
  274. package/dist/ui/markdown-stream.js.map +1 -0
  275. package/dist/ui/plan-renderer.d.ts +36 -0
  276. package/dist/ui/plan-renderer.d.ts.map +1 -0
  277. package/dist/ui/plan-renderer.js +79 -0
  278. package/dist/ui/plan-renderer.js.map +1 -0
  279. package/dist/ui/prompt.d.ts +11 -0
  280. package/dist/ui/prompt.d.ts.map +1 -0
  281. package/dist/ui/prompt.js +1960 -0
  282. package/dist/ui/prompt.js.map +1 -0
  283. package/dist/ui/render-primitives.d.ts +117 -0
  284. package/dist/ui/render-primitives.d.ts.map +1 -0
  285. package/dist/ui/render-primitives.js +322 -0
  286. package/dist/ui/render-primitives.js.map +1 -0
  287. package/dist/ui/render.d.ts +133 -0
  288. package/dist/ui/render.d.ts.map +1 -0
  289. package/dist/ui/render.js +547 -0
  290. package/dist/ui/render.js.map +1 -0
  291. package/dist/ui/session-header.d.ts +30 -0
  292. package/dist/ui/session-header.d.ts.map +1 -0
  293. package/dist/ui/session-header.js +74 -0
  294. package/dist/ui/session-header.js.map +1 -0
  295. package/dist/workspace-guard.d.ts +68 -0
  296. package/dist/workspace-guard.d.ts.map +1 -0
  297. package/dist/workspace-guard.js +168 -0
  298. package/dist/workspace-guard.js.map +1 -0
  299. package/dist/workspace-lock.d.ts +27 -0
  300. package/dist/workspace-lock.d.ts.map +1 -0
  301. package/dist/workspace-lock.js +95 -0
  302. package/dist/workspace-lock.js.map +1 -0
  303. package/package.json +63 -0
@@ -0,0 +1,1247 @@
1
+ import { colors } from '../ui/colors.js';
2
+ import { ProvidersManager } from './providers-manager.js';
3
+ import { providerCooldown } from './provider-cooldown.js';
4
+ import { reconstructPartialResponse, isMidStreamResumable, StreamResumeExhaustedError, } from './stream-glue.js';
5
+ import { DEFAULT_API_URL } from '../config.js';
6
+ import { recordTelemetry, telemetry } from './telemetry.js';
7
+ import { getProviderKeyVault } from '../runtime/credential-vault.js';
8
+ import { extractTextFromContent } from '../shared/content.js';
9
+ /* ──────────────────────── Constants ──────────────────────── */
10
+ const MAX_RETRIES = 5;
11
+ const BASE_DELAY_MS = 1500;
12
+ const RETRYABLE_STATUS_CODES = new Set([408, 429, 500, 502, 503]);
13
+ const BASE_URL = process.env.FIXO_API_URL || DEFAULT_API_URL;
14
+ /** Wrapper around `providerCooldown.recordFailure` that also emits a
15
+ * telemetry event. Keeps the 6 callsites terse. */
16
+ function trackProviderError(providerId, status, message) {
17
+ const cooldownMs = providerCooldown.recordFailure(providerId, status, message);
18
+ if (cooldownMs > 0) {
19
+ recordTelemetry(telemetry.cooldown({
20
+ providerId,
21
+ status,
22
+ cooldownMs,
23
+ reason: message.slice(0, 200),
24
+ }));
25
+ }
26
+ else if (status >= 400) {
27
+ recordTelemetry(telemetry.providerError({ providerId, status, message: message.slice(0, 200) }));
28
+ }
29
+ return cooldownMs;
30
+ }
31
+ /* ──────────────────────── ThinkTagParser ──────────────────────── */
32
+ export var ContentType;
33
+ (function (ContentType) {
34
+ ContentType["TEXT"] = "text";
35
+ ContentType["THINKING"] = "thinking";
36
+ })(ContentType || (ContentType = {}));
37
+ export class ThinkTagParser {
38
+ OPEN_TAG = '<think>';
39
+ CLOSE_TAG = '</think>';
40
+ _buffer = '';
41
+ _in_think_tag = false;
42
+ get in_think_mode() {
43
+ return this._in_think_tag;
44
+ }
45
+ *feed(content) {
46
+ this._buffer += content;
47
+ while (this._buffer) {
48
+ const prev_len = this._buffer.length;
49
+ let chunk = null;
50
+ if (!this._in_think_tag) {
51
+ chunk = this._parse_outside_think();
52
+ }
53
+ else {
54
+ chunk = this._parse_inside_think();
55
+ }
56
+ if (chunk) {
57
+ yield chunk;
58
+ }
59
+ else if (this._buffer.length === prev_len) {
60
+ break;
61
+ }
62
+ }
63
+ }
64
+ _parse_outside_think() {
65
+ const think_start = this._buffer.indexOf(this.OPEN_TAG);
66
+ const orphan_close = this._buffer.indexOf(this.CLOSE_TAG);
67
+ if (orphan_close !== -1 && (think_start === -1 || orphan_close < think_start)) {
68
+ const pre_orphan = this._buffer.slice(0, orphan_close);
69
+ this._buffer = this._buffer.slice(orphan_close + this.CLOSE_TAG.length);
70
+ if (pre_orphan) {
71
+ return { type: ContentType.TEXT, content: pre_orphan };
72
+ }
73
+ return null;
74
+ }
75
+ if (think_start === -1) {
76
+ const last_bracket = this._buffer.lastIndexOf('<');
77
+ if (last_bracket !== -1) {
78
+ const potential_tag = this._buffer.slice(last_bracket);
79
+ const tag_len = potential_tag.length;
80
+ if ((tag_len < this.OPEN_TAG.length && this.OPEN_TAG.startsWith(potential_tag)) ||
81
+ (tag_len < this.CLOSE_TAG.length && this.CLOSE_TAG.startsWith(potential_tag))) {
82
+ const emit = this._buffer.slice(0, last_bracket);
83
+ this._buffer = this._buffer.slice(last_bracket);
84
+ if (emit) {
85
+ return { type: ContentType.TEXT, content: emit };
86
+ }
87
+ return null;
88
+ }
89
+ }
90
+ const emit = this._buffer;
91
+ this._buffer = '';
92
+ if (emit) {
93
+ return { type: ContentType.TEXT, content: emit };
94
+ }
95
+ return null;
96
+ }
97
+ const pre_think = this._buffer.slice(0, think_start);
98
+ this._buffer = this._buffer.slice(think_start + this.OPEN_TAG.length);
99
+ this._in_think_tag = true;
100
+ if (pre_think) {
101
+ return { type: ContentType.TEXT, content: pre_think };
102
+ }
103
+ return null;
104
+ }
105
+ _parse_inside_think() {
106
+ const think_end = this._buffer.indexOf(this.CLOSE_TAG);
107
+ if (think_end === -1) {
108
+ const last_bracket = this._buffer.lastIndexOf('<');
109
+ if (last_bracket !== -1 && this._buffer.length - last_bracket < this.CLOSE_TAG.length) {
110
+ const potential_tag = this._buffer.slice(last_bracket);
111
+ if (this.CLOSE_TAG.startsWith(potential_tag)) {
112
+ const emit = this._buffer.slice(0, last_bracket);
113
+ this._buffer = this._buffer.slice(last_bracket);
114
+ if (emit) {
115
+ return { type: ContentType.THINKING, content: emit };
116
+ }
117
+ return null;
118
+ }
119
+ }
120
+ const emit = this._buffer;
121
+ this._buffer = '';
122
+ if (emit) {
123
+ return { type: ContentType.THINKING, content: emit };
124
+ }
125
+ return null;
126
+ }
127
+ const thinking_content = this._buffer.slice(0, think_end);
128
+ this._buffer = this._buffer.slice(think_end + this.CLOSE_TAG.length);
129
+ this._in_think_tag = false;
130
+ if (thinking_content) {
131
+ return { type: ContentType.THINKING, content: thinking_content };
132
+ }
133
+ return null;
134
+ }
135
+ flush() {
136
+ if (this._buffer) {
137
+ const chunk_type = this._in_think_tag ? ContentType.THINKING : ContentType.TEXT;
138
+ const content = this._buffer;
139
+ this._buffer = '';
140
+ return { type: chunk_type, content };
141
+ }
142
+ return null;
143
+ }
144
+ }
145
+ /* ──────────────────────── HttpError ──────────────────────── */
146
+ export class HttpError extends Error {
147
+ status;
148
+ constructor(status, message) {
149
+ super(message);
150
+ this.status = status;
151
+ this.name = 'HttpError';
152
+ }
153
+ }
154
+ /* ──────────────────────── AgentClient ──────────────────────── */
155
+ export class AgentClient {
156
+ baseUrl;
157
+ apiKey;
158
+ verbose;
159
+ constructor(apiKey, apiUrl, verbose = false) {
160
+ this.baseUrl = process.env.FIXO_API_URL || apiUrl || BASE_URL;
161
+ this.apiKey = apiKey;
162
+ this.verbose = verbose;
163
+ }
164
+ resolveDirectConfig(model) {
165
+ const modelLower = model.toLowerCase();
166
+ let providerName = null;
167
+ if (modelLower.startsWith('gpt-') || modelLower.startsWith('o3-') || modelLower.startsWith('o4-') || modelLower.startsWith('o1-')) {
168
+ providerName = 'openai';
169
+ }
170
+ else if (modelLower.startsWith('claude-')) {
171
+ providerName = 'anthropic';
172
+ }
173
+ else if (modelLower.startsWith('gemini-')) {
174
+ providerName = 'google';
175
+ }
176
+ else {
177
+ const definitions = ProvidersManager.getAllDefinitions();
178
+ for (const def of definitions) {
179
+ if (def.models.some(m => modelLower.includes(m.toLowerCase()))) {
180
+ providerName = def.name;
181
+ break;
182
+ }
183
+ }
184
+ if (!providerName) {
185
+ for (const def of definitions) {
186
+ if (modelLower.startsWith(def.name + '/') || modelLower.startsWith(def.name + ':')) {
187
+ providerName = def.name;
188
+ break;
189
+ }
190
+ }
191
+ }
192
+ }
193
+ if (providerName) {
194
+ const direct = ProvidersManager.getDirectConfig(providerName);
195
+ if (direct) {
196
+ const def = ProvidersManager.getDefinition(providerName);
197
+ return {
198
+ baseUrl: direct.baseUrl,
199
+ displayName: direct.displayName,
200
+ providerName,
201
+ openAICompat: def ? def.openAICompat : true,
202
+ };
203
+ }
204
+ }
205
+ return null;
206
+ }
207
+ /**
208
+ * Maps a model id to the provider that will actually serve the
209
+ * request — used as the key for `providerCooldown` tracking. The
210
+ * `freellmapi` sentinel covers the proxy path; everything else
211
+ * routes through a direct provider.
212
+ */
213
+ getProviderId(model) {
214
+ const direct = this.resolveDirectConfig(model);
215
+ if (direct)
216
+ return direct.providerName;
217
+ return 'freellmapi';
218
+ }
219
+ /* ─── Non-streaming chat ─── */
220
+ async chat(messages, model, options = {}) {
221
+ const providerId = this.getProviderId(model);
222
+ providerCooldown.assertAvailable(providerId);
223
+ const direct = this.resolveDirectConfig(model);
224
+ const isAnthropicDirect = direct && direct.providerName === 'anthropic';
225
+ let requestUrl = `${this.baseUrl}/chat/completions`;
226
+ let headers = {
227
+ 'Content-Type': 'application/json',
228
+ 'Authorization': `Bearer ${this.apiKey}`,
229
+ };
230
+ let body = '';
231
+ if (direct) {
232
+ // Pillar 4: source the API key from the credential vault so
233
+ // the raw value never lands in a return value, an error
234
+ // payload, or a log line. The key is reachable only inside
235
+ // the withApiKey callback.
236
+ const vault = getProviderKeyVault();
237
+ if (isAnthropicDirect) {
238
+ requestUrl = `${direct.baseUrl}/messages`;
239
+ headers = await vault.withApiKey(direct.providerName, (key) => ({
240
+ 'Content-Type': 'application/json',
241
+ 'x-api-key': key,
242
+ 'anthropic-version': '2023-06-01',
243
+ }));
244
+ body = JSON.stringify(translateOpenAIToAnthropic(messages, model, options));
245
+ }
246
+ else {
247
+ requestUrl = `${direct.baseUrl}/chat/completions`;
248
+ headers = await vault.withApiKey(direct.providerName, (key) => {
249
+ const h = {
250
+ 'Content-Type': 'application/json',
251
+ 'Authorization': `Bearer ${key}`,
252
+ };
253
+ if (direct.providerName === 'zen' || direct.providerName === 'openrouter') {
254
+ h['HTTP-Referer'] = 'https://opencode.ai/';
255
+ h['X-Title'] = 'opencode';
256
+ }
257
+ else if (direct.providerName === 'nvidia') {
258
+ h['HTTP-Referer'] = 'https://opencode.ai/';
259
+ h['X-Title'] = 'opencode';
260
+ h['X-BILLING-INVOKE-ORIGIN'] = 'OpenCode';
261
+ }
262
+ else if (direct.providerName === 'cerebras') {
263
+ h['X-Cerebras-3rd-Party-Integration'] = 'opencode';
264
+ }
265
+ return h;
266
+ });
267
+ const bodyObj = {
268
+ model,
269
+ messages: messagesForOpenAIWire(messages),
270
+ stream: false,
271
+ ...options,
272
+ };
273
+ body = JSON.stringify(bodyObj);
274
+ }
275
+ }
276
+ else {
277
+ const hasTools = options.tools && Array.isArray(options.tools) && options.tools.length > 0;
278
+ const bodyObj = {
279
+ model,
280
+ messages: messagesForOpenAIWire(messages),
281
+ stream: false,
282
+ ...options,
283
+ };
284
+ if (hasTools) {
285
+ bodyObj.x_requires_tools = true;
286
+ headers['X-Requires-Tools'] = 'true';
287
+ }
288
+ if (options.agent_task_type) {
289
+ bodyObj.x_agent_task_type = options.agent_task_type;
290
+ bodyObj.x_required_capabilities = options.required_capabilities ?? [];
291
+ headers['X-Agent-Task-Type'] = options.agent_task_type;
292
+ }
293
+ body = JSON.stringify(bodyObj);
294
+ }
295
+ let lastError = null;
296
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
297
+ try {
298
+ const response = await fetch(requestUrl, {
299
+ method: 'POST',
300
+ headers,
301
+ body,
302
+ signal: AbortSignal.timeout(60000), // 60s timeout
303
+ });
304
+ // Non-retryable errors
305
+ if (response.status === 413) {
306
+ throw new Error(`Context too large (413). Reduce pinned files or use a model with a larger context window.`);
307
+ }
308
+ if (response.status === 404) {
309
+ throw new Error(`Model not found (404). Try a different model with /model <name>.`);
310
+ }
311
+ // Retryable errors
312
+ if (RETRYABLE_STATUS_CODES.has(response.status)) {
313
+ trackProviderError(providerId, response.status, `HTTP ${response.status}`);
314
+ const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
315
+ if (attempt < MAX_RETRIES) {
316
+ console.log(`${colors.yellow}⚠ [API] Error ${response.status}. Retrying in ${(delayMs / 1000).toFixed(1)}s (${attempt + 1}/${MAX_RETRIES})${colors.reset}`);
317
+ await sleep(delayMs);
318
+ continue;
319
+ }
320
+ }
321
+ if (!response.ok) {
322
+ const errorText = await response.text().catch(() => 'Unknown error');
323
+ throw new Error(`API error (${response.status}): ${errorText}`);
324
+ }
325
+ const rawData = await response.json();
326
+ const data = isAnthropicDirect ? translateAnthropicToOpenAI(rawData) : rawData;
327
+ const choice = data.choices[0];
328
+ providerCooldown.recordSuccess(providerId);
329
+ // ChatResult.content is `string | null`. The widened
330
+ // ChatMessage.content union allows blocks on input, but
331
+ // every provider we ship returns text-only assistant
332
+ // messages, so we collapse to a string defensively.
333
+ const respContent = choice?.message?.content;
334
+ return {
335
+ content: respContent == null
336
+ ? null
337
+ : typeof respContent === 'string'
338
+ ? respContent
339
+ : extractTextFromContent(respContent),
340
+ tool_calls: choice?.message?.tool_calls ?? null,
341
+ usage: data.usage ?? { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
342
+ model: data.model,
343
+ finish_reason: choice?.finish_reason ?? null,
344
+ };
345
+ }
346
+ catch (error) {
347
+ lastError = error instanceof Error ? error : new Error(String(error));
348
+ // Don't retry context too large errors
349
+ if (lastError.message.includes('413')) {
350
+ throw lastError;
351
+ }
352
+ // 404 from the local FreeLLMAPI proxy = model not in catalog (user typo).
353
+ // This is a user error, not retryable.
354
+ if (lastError.message.includes('API error (404)')) {
355
+ throw lastError;
356
+ }
357
+ // 502 from the local proxy = all configured providers exhausted/failed.
358
+ // Give actionable error instead of generic "retry".
359
+ if (lastError.message.includes('API error (502)') || lastError.message.includes('502')) {
360
+ const isAllExhausted = lastError.message.toLowerCase().includes('all models') ||
361
+ lastError.message.toLowerCase().includes('provider error');
362
+ if (isAllExhausted || attempt >= MAX_RETRIES - 1) {
363
+ const helpMsg = lastError.message.toLowerCase().includes('provider error')
364
+ ? `Provider error: all configured models failed or are rate-limited.\n → Open http://localhost:5173 → API Keys → add more provider keys.\n → Or wait a few minutes for rate limits to reset.`
365
+ : lastError.message;
366
+ throw new Error(helpMsg);
367
+ }
368
+ }
369
+ // Retry network/timeout errors
370
+ const isNetworkError = lastError.name === 'TimeoutError' ||
371
+ lastError.message.includes('Timeout') ||
372
+ lastError.message.includes('ECONNREFUSED') ||
373
+ lastError.message.includes('ECONNRESET') ||
374
+ lastError.message.includes('fetch failed') ||
375
+ lastError.message.includes('ETIMEDOUT');
376
+ if (lastError.message.includes('ECONNREFUSED') || lastError.message.includes('fetch failed')) {
377
+ if (attempt >= MAX_RETRIES - 1) {
378
+ throw new Error(`Cannot connect to FreeLLMAPI server at ${this.baseUrl}.\n` +
379
+ ` → Make sure the server is running: npm run dev\n` +
380
+ ` → Then restart the CLI: npm run cli`);
381
+ }
382
+ }
383
+ if (isNetworkError && attempt < MAX_RETRIES) {
384
+ trackProviderError(providerId, 0, lastError.message.slice(0, 200));
385
+ const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
386
+ console.log(`${colors.yellow}⚠ [Network] ${lastError.message.slice(0, 60)}. Retrying in ${(delayMs / 1000).toFixed(1)}s (${attempt + 1}/${MAX_RETRIES})${colors.reset}`);
387
+ await sleep(delayMs);
388
+ continue;
389
+ }
390
+ if (attempt >= MAX_RETRIES)
391
+ break;
392
+ if (!isNetworkError)
393
+ throw lastError;
394
+ }
395
+ }
396
+ throw lastError ?? new Error('All retry attempts exhausted.');
397
+ }
398
+ /* ─── Streaming chat (SSE) ─── */
399
+ async *executeSingleChatStreamAttempt(requestUrl, headers, body, model, isAnthropicDirect) {
400
+ const response = await fetch(requestUrl, {
401
+ method: 'POST',
402
+ headers,
403
+ body,
404
+ signal: AbortSignal.timeout(60000), // 60s timeout
405
+ });
406
+ if (response.status === 413) {
407
+ throw new Error(`Context too large (413). Reduce pinned files or use a model with a larger context window.`);
408
+ }
409
+ if (response.status === 404) {
410
+ throw new Error(`Model not found (404). Try a different model with /model <name>.`);
411
+ }
412
+ if (RETRYABLE_STATUS_CODES.has(response.status)) {
413
+ throw new HttpError(response.status, `API error ${response.status}`);
414
+ }
415
+ if (!response.ok) {
416
+ const errorText = await response.text().catch(() => 'Unknown error');
417
+ throw new Error(`API error (${response.status}): ${errorText}`);
418
+ }
419
+ if (!response.body) {
420
+ throw new Error('Response body is null — streaming not supported.');
421
+ }
422
+ // Parse SSE stream
423
+ const reader = response.body.getReader();
424
+ const decoder = new TextDecoder();
425
+ let buffer = '';
426
+ let accumulatedUsage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
427
+ let accumulatedModel = model;
428
+ const parser = new ThinkTagParser();
429
+ let currentToolCallIndex = 0;
430
+ while (true) {
431
+ const { done, value } = await reader.read();
432
+ if (done)
433
+ break;
434
+ buffer += decoder.decode(value, { stream: true });
435
+ const lines = buffer.split('\n');
436
+ buffer = lines.pop() ?? '';
437
+ for (const line of lines) {
438
+ const trimmed = line.trim();
439
+ if (!trimmed || trimmed === ':')
440
+ continue; // Skip comments and empty lines
441
+ if (isAnthropicDirect) {
442
+ if (trimmed.startsWith('event: ')) {
443
+ continue;
444
+ }
445
+ if (!trimmed.startsWith('data: '))
446
+ continue;
447
+ const data = trimmed.slice(6);
448
+ let chunk;
449
+ try {
450
+ chunk = JSON.parse(data);
451
+ }
452
+ catch {
453
+ // skip malformed JSON chunks
454
+ continue;
455
+ }
456
+ if (chunk && (chunk.type === 'error' || chunk.error)) {
457
+ const errMsg = chunk.error && chunk.error.message
458
+ ? chunk.error.message
459
+ : (chunk.message || JSON.stringify(chunk));
460
+ throw new Error(`Anthropic stream error: ${errMsg}`);
461
+ }
462
+ if (chunk.type === 'message_start') {
463
+ if (chunk.message && chunk.message.model) {
464
+ accumulatedModel = chunk.message.model;
465
+ }
466
+ }
467
+ else if (chunk.type === 'content_block_start') {
468
+ const block = chunk.content_block;
469
+ currentToolCallIndex = chunk.index ?? 0;
470
+ if (block && block.type === 'tool_use') {
471
+ yield {
472
+ type: 'tool_call_start',
473
+ tool_call: {
474
+ index: currentToolCallIndex,
475
+ id: block.id,
476
+ function: {
477
+ name: block.name,
478
+ arguments: '',
479
+ }
480
+ }
481
+ };
482
+ }
483
+ }
484
+ else if (chunk.type === 'content_block_delta') {
485
+ const delta = chunk.delta;
486
+ if (delta) {
487
+ if (delta.type === 'text_delta' && delta.text) {
488
+ for (const parsedChunk of parser.feed(delta.text)) {
489
+ if (parsedChunk.type === ContentType.THINKING) {
490
+ yield { type: 'thinking', thinking: parsedChunk.content };
491
+ }
492
+ else {
493
+ yield { type: 'content', content: parsedChunk.content };
494
+ }
495
+ }
496
+ }
497
+ else if (delta.type === 'input_json_delta' && delta.partial_json) {
498
+ yield {
499
+ type: 'tool_call_delta',
500
+ tool_call: {
501
+ index: currentToolCallIndex,
502
+ function: {
503
+ arguments: delta.partial_json,
504
+ }
505
+ }
506
+ };
507
+ }
508
+ }
509
+ }
510
+ else if (chunk.type === 'message_delta') {
511
+ if (chunk.usage) {
512
+ accumulatedUsage = {
513
+ prompt_tokens: chunk.usage.input_tokens || 0,
514
+ completion_tokens: chunk.usage.output_tokens || 0,
515
+ total_tokens: (chunk.usage.input_tokens || 0) + (chunk.usage.output_tokens || 0),
516
+ };
517
+ }
518
+ }
519
+ else if (chunk.type === 'message_stop') {
520
+ const flushed = parser.flush();
521
+ if (flushed) {
522
+ if (flushed.type === ContentType.THINKING) {
523
+ yield { type: 'thinking', thinking: flushed.content };
524
+ }
525
+ else {
526
+ yield { type: 'content', content: flushed.content };
527
+ }
528
+ }
529
+ yield {
530
+ type: 'done',
531
+ usage: accumulatedUsage,
532
+ model: accumulatedModel,
533
+ };
534
+ }
535
+ }
536
+ else {
537
+ if (!trimmed.startsWith('data: '))
538
+ continue;
539
+ const data = trimmed.slice(6);
540
+ if (data === '[DONE]') {
541
+ const flushed = parser.flush();
542
+ if (flushed) {
543
+ if (flushed.type === ContentType.THINKING) {
544
+ yield { type: 'thinking', thinking: flushed.content };
545
+ }
546
+ else {
547
+ yield { type: 'content', content: flushed.content };
548
+ }
549
+ }
550
+ yield {
551
+ type: 'done',
552
+ usage: accumulatedUsage,
553
+ model: accumulatedModel,
554
+ };
555
+ return;
556
+ }
557
+ let chunk;
558
+ try {
559
+ chunk = JSON.parse(data);
560
+ }
561
+ catch {
562
+ // Skip malformed JSON chunks
563
+ if (this.verbose) {
564
+ console.log(`${colors.gray}[stream] Skipped malformed chunk: ${data.slice(0, 80)}${colors.reset}`);
565
+ }
566
+ continue;
567
+ }
568
+ if (chunk && chunk.error) {
569
+ const errMsg = typeof chunk.error === 'object' && chunk.error.message
570
+ ? chunk.error.message
571
+ : JSON.stringify(chunk.error);
572
+ throw new Error(`Stream error: ${errMsg}`);
573
+ }
574
+ if (chunk.model)
575
+ accumulatedModel = chunk.model;
576
+ if (chunk.usage) {
577
+ accumulatedUsage = chunk.usage;
578
+ }
579
+ const choice = chunk.choices?.[0];
580
+ if (!choice)
581
+ continue;
582
+ // reasoning_content delta
583
+ if (choice.delta.reasoning_content) {
584
+ yield {
585
+ type: 'thinking',
586
+ thinking: choice.delta.reasoning_content,
587
+ };
588
+ }
589
+ // Content delta
590
+ if (choice.delta?.content) {
591
+ for (const parsedChunk of parser.feed(choice.delta.content)) {
592
+ if (parsedChunk.type === ContentType.THINKING) {
593
+ yield {
594
+ type: 'thinking',
595
+ thinking: parsedChunk.content,
596
+ };
597
+ }
598
+ else {
599
+ yield {
600
+ type: 'content',
601
+ content: parsedChunk.content,
602
+ };
603
+ }
604
+ }
605
+ }
606
+ // Tool call deltas
607
+ if (choice.delta?.tool_calls) {
608
+ for (const tc of choice.delta.tool_calls) {
609
+ const idx = tc.index ?? 0;
610
+ if (tc.id) {
611
+ yield {
612
+ type: 'tool_call_start',
613
+ tool_call: {
614
+ index: idx,
615
+ id: tc.id,
616
+ function: {
617
+ name: tc.function?.name ?? '',
618
+ arguments: tc.function?.arguments ?? '',
619
+ },
620
+ },
621
+ };
622
+ }
623
+ else {
624
+ yield {
625
+ type: 'tool_call_delta',
626
+ tool_call: {
627
+ index: idx,
628
+ function: {
629
+ arguments: tc.function?.arguments ?? '',
630
+ },
631
+ },
632
+ };
633
+ }
634
+ }
635
+ }
636
+ // Finish reason
637
+ if (choice.finish_reason) {
638
+ const flushed = parser.flush();
639
+ if (flushed) {
640
+ if (flushed.type === ContentType.THINKING) {
641
+ yield { type: 'thinking', thinking: flushed.content };
642
+ }
643
+ else {
644
+ yield { type: 'content', content: flushed.content };
645
+ }
646
+ }
647
+ yield {
648
+ type: 'done',
649
+ finish_reason: choice.finish_reason,
650
+ usage: accumulatedUsage,
651
+ model: accumulatedModel,
652
+ };
653
+ }
654
+ }
655
+ }
656
+ }
657
+ // Stream ended without [DONE]
658
+ const flushed = parser.flush();
659
+ if (flushed) {
660
+ if (flushed.type === ContentType.THINKING) {
661
+ yield { type: 'thinking', thinking: flushed.content };
662
+ }
663
+ else {
664
+ yield { type: 'content', content: flushed.content };
665
+ }
666
+ }
667
+ yield {
668
+ type: 'done',
669
+ usage: accumulatedUsage,
670
+ model: accumulatedModel,
671
+ };
672
+ }
673
+ async *chatStream(messages, model, options = {}) {
674
+ const providerId = this.getProviderId(model);
675
+ providerCooldown.assertAvailable(providerId);
676
+ const direct = this.resolveDirectConfig(model);
677
+ const isAnthropicDirect = !!(direct && direct.providerName === 'anthropic');
678
+ let requestUrl = `${this.baseUrl}/chat/completions`;
679
+ let headers = {
680
+ 'Content-Type': 'application/json',
681
+ 'Authorization': `Bearer ${this.apiKey}`,
682
+ };
683
+ let body = '';
684
+ if (direct) {
685
+ // Pillar 4: source the API key from the credential vault.
686
+ const vault = getProviderKeyVault();
687
+ if (isAnthropicDirect) {
688
+ requestUrl = `${direct.baseUrl}/messages`;
689
+ headers = await vault.withApiKey(direct.providerName, (key) => ({
690
+ 'Content-Type': 'application/json',
691
+ 'x-api-key': key,
692
+ 'anthropic-version': '2023-06-01',
693
+ }));
694
+ const payload = translateOpenAIToAnthropic(messages, model, options);
695
+ payload.stream = true;
696
+ body = JSON.stringify(payload);
697
+ }
698
+ else {
699
+ requestUrl = `${direct.baseUrl}/chat/completions`;
700
+ headers = await vault.withApiKey(direct.providerName, (key) => {
701
+ const h = {
702
+ 'Content-Type': 'application/json',
703
+ 'Authorization': `Bearer ${key}`,
704
+ };
705
+ if (direct.providerName === 'zen' || direct.providerName === 'openrouter') {
706
+ h['HTTP-Referer'] = 'https://opencode.ai/';
707
+ h['X-Title'] = 'opencode';
708
+ }
709
+ else if (direct.providerName === 'nvidia') {
710
+ h['HTTP-Referer'] = 'https://opencode.ai/';
711
+ h['X-Title'] = 'opencode';
712
+ h['X-BILLING-INVOKE-ORIGIN'] = 'OpenCode';
713
+ }
714
+ else if (direct.providerName === 'cerebras') {
715
+ h['X-Cerebras-3rd-Party-Integration'] = 'opencode';
716
+ }
717
+ return h;
718
+ });
719
+ const bodyObj = {
720
+ model,
721
+ messages: messagesForOpenAIWire(messages),
722
+ stream: true,
723
+ ...options,
724
+ };
725
+ body = JSON.stringify(bodyObj);
726
+ }
727
+ }
728
+ else {
729
+ const hasTools = options.tools && Array.isArray(options.tools) && options.tools.length > 0;
730
+ const bodyObj = {
731
+ model,
732
+ messages: messagesForOpenAIWire(messages),
733
+ stream: true,
734
+ ...options,
735
+ };
736
+ if (hasTools) {
737
+ bodyObj.x_requires_tools = true;
738
+ headers['X-Requires-Tools'] = 'true';
739
+ }
740
+ if (options.agent_task_type) {
741
+ bodyObj.x_agent_task_type = options.agent_task_type;
742
+ bodyObj.x_required_capabilities = options.required_capabilities ?? [];
743
+ headers['X-Agent-Task-Type'] = options.agent_task_type;
744
+ }
745
+ body = JSON.stringify(bodyObj);
746
+ }
747
+ let lastError = null;
748
+ let hasYielded = false;
749
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
750
+ try {
751
+ const stream = this.executeSingleChatStreamAttempt(requestUrl, headers, body, model, isAnthropicDirect);
752
+ for await (const chunk of stream) {
753
+ hasYielded = true;
754
+ yield chunk;
755
+ }
756
+ providerCooldown.recordSuccess(providerId);
757
+ return; // Success — don't retry
758
+ }
759
+ catch (error) {
760
+ lastError = error instanceof Error ? error : new Error(String(error));
761
+ if (hasYielded) {
762
+ // If we have already yielded some chunks, do not retry because we cannot
763
+ // rewind/resume the stream. Retrying would yield duplicate tokens on stdout.
764
+ throw lastError;
765
+ }
766
+ // Don't retry context too large
767
+ if (lastError.message.includes('413')) {
768
+ throw lastError;
769
+ }
770
+ // 404 from proxy = model not in catalog (user typo), not retryable
771
+ if (lastError.message.includes('API error (404)')) {
772
+ throw lastError;
773
+ }
774
+ // 502 from proxy = all providers exhausted
775
+ if (lastError.message.includes('API error (502)') || lastError.message.includes('502')) {
776
+ const isAllExhausted = lastError.message.toLowerCase().includes('all models') ||
777
+ lastError.message.toLowerCase().includes('provider error');
778
+ if (isAllExhausted || attempt >= MAX_RETRIES - 1) {
779
+ const helpMsg = lastError.message.toLowerCase().includes('provider error')
780
+ ? `Provider error: all configured models failed or are rate-limited.\n → Open http://localhost:5173 → API Keys → add more provider keys.\n → Or wait a few minutes for rate limits to reset.`
781
+ : lastError.message;
782
+ throw new Error(helpMsg);
783
+ }
784
+ }
785
+ const isNetworkError = lastError.name === 'TimeoutError' ||
786
+ lastError.message.includes('Timeout') ||
787
+ lastError.message.includes('ECONNREFUSED') ||
788
+ lastError.message.includes('ECONNRESET') ||
789
+ lastError.message.includes('fetch failed') ||
790
+ lastError.message.includes('ETIMEDOUT');
791
+ if (lastError.message.includes('ECONNREFUSED') || lastError.message.includes('fetch failed')) {
792
+ if (attempt >= MAX_RETRIES - 1) {
793
+ throw new Error(`Cannot connect to FreeLLMAPI server at ${this.baseUrl}.\n` +
794
+ ` → Make sure the server is running: npm run dev\n` +
795
+ ` → Then restart the CLI: npm run cli`);
796
+ }
797
+ }
798
+ if (lastError instanceof HttpError && RETRYABLE_STATUS_CODES.has(lastError.status)) {
799
+ trackProviderError(providerId, lastError.status, `HTTP ${lastError.status}`);
800
+ const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
801
+ if (attempt < MAX_RETRIES) {
802
+ console.log(`${colors.yellow}⚠ [API] Error ${lastError.status}. Retrying in ${(delayMs / 1000).toFixed(1)}s (${attempt + 1}/${MAX_RETRIES})${colors.reset}`);
803
+ await sleep(delayMs);
804
+ continue;
805
+ }
806
+ }
807
+ if (isNetworkError && attempt < MAX_RETRIES) {
808
+ trackProviderError(providerId, 0, lastError.message.slice(0, 200));
809
+ const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
810
+ console.log(`${colors.yellow}⚠ [Network] ${lastError.message.slice(0, 60)}. Retrying in ${(delayMs / 1000).toFixed(1)}s (${attempt + 1}/${MAX_RETRIES})${colors.reset}`);
811
+ await sleep(delayMs);
812
+ continue;
813
+ }
814
+ if (attempt >= MAX_RETRIES)
815
+ break;
816
+ if (!isNetworkError)
817
+ throw lastError;
818
+ }
819
+ }
820
+ throw lastError ?? new Error('All streaming retry attempts exhausted.');
821
+ }
822
+ /**
823
+ * Streaming chat with autonomous mid-stream resume.
824
+ *
825
+ * If the underlying `chatStream` throws *after* at least one chunk
826
+ * has been yielded, the resume engine inspects the partial response,
827
+ * appends a "continue from here" payload to the working message list,
828
+ * and starts a fresh streaming attempt. The consumer sees a single
829
+ * continuous `AsyncGenerator<StreamChunk>` — the resume is invisible.
830
+ *
831
+ * The engine respects:
832
+ * - `maxResumeAttempts` (default 3) — additional attempts beyond
833
+ * this throw `StreamResumeExhaustedError`.
834
+ * - `isMidStreamResumable` — user aborts and 4xx are never resumed.
835
+ * - Cuts inside a tool call — the partial text up to the tool call
836
+ * boundary is preserved, but the call itself cannot be resumed.
837
+ *
838
+ * The method is *additive* and does not change the existing
839
+ * `chatStream` contract. Callers opt in by switching to this entry
840
+ * point (see `SingleAgent.streamResponse`).
841
+ */
842
+ async *chatStreamWithResume(messages, model, options = {}, maxResumeAttempts = 3) {
843
+ const workingMessages = messages.map((m) => ({ ...m }));
844
+ let resumeAttempt = 0;
845
+ // Per-attempt state. Reset at the top of every loop iteration.
846
+ let attemptChunks = [];
847
+ let attemptYielded = false;
848
+ while (true) {
849
+ attemptChunks = [];
850
+ attemptYielded = false;
851
+ try {
852
+ for await (const chunk of this.chatStream(workingMessages, model, options)) {
853
+ attemptChunks.push(chunk);
854
+ attemptYielded = true;
855
+ yield chunk;
856
+ }
857
+ return; // Natural completion.
858
+ }
859
+ catch (err) {
860
+ // Pre-stream error — the inner chatStream never even started
861
+ // (413, 404, 502 all-models-exhausted, etc.). Do not attempt a
862
+ // resume; bubble up unchanged so the agent loop can react.
863
+ if (!attemptYielded) {
864
+ throw err;
865
+ }
866
+ // If the inner stream was already yielding a tool call, the
867
+ // tool call is atomic and cannot be resumed.
868
+ const last = attemptChunks[attemptChunks.length - 1];
869
+ const cutDuringToolCall = !!last && (last.type === 'tool_call_start' || last.type === 'tool_call_delta');
870
+ // Errors that are explicitly not candidates for a resume.
871
+ if (!isMidStreamResumable(err) || cutDuringToolCall) {
872
+ recordTelemetry(telemetry.streamResume({
873
+ resumeAttempt,
874
+ partialTokens: Math.ceil(reconstructPartialResponse(attemptChunks).length / 4),
875
+ ok: false,
876
+ reason: cutDuringToolCall ? 'tool-call-cut' : 'non-resumable',
877
+ }));
878
+ throw new StreamResumeExhaustedError(cutDuringToolCall
879
+ ? `Stream cut during a tool call after ${attemptChunks.length} chunks; cannot resume.`
880
+ : err instanceof Error
881
+ ? `Stream cut and error is non-resumable: ${err.message}`
882
+ : 'Stream cut and error is non-resumable.', {
883
+ resumeAttempt,
884
+ chunks: attemptChunks,
885
+ partial: reconstructPartialResponse(attemptChunks),
886
+ cutDuringToolCall,
887
+ });
888
+ }
889
+ if (resumeAttempt >= maxResumeAttempts) {
890
+ recordTelemetry(telemetry.streamResume({
891
+ resumeAttempt,
892
+ partialTokens: Math.ceil(reconstructPartialResponse(attemptChunks).length / 4),
893
+ ok: false,
894
+ reason: 'exhausted',
895
+ }));
896
+ throw new StreamResumeExhaustedError(`Stream resume attempts exhausted (${resumeAttempt}/${maxResumeAttempts}).`, {
897
+ resumeAttempt,
898
+ chunks: attemptChunks,
899
+ partial: reconstructPartialResponse(attemptChunks),
900
+ });
901
+ }
902
+ const partial = reconstructPartialResponse(attemptChunks);
903
+ if (partial === '') {
904
+ recordTelemetry(telemetry.streamResume({ resumeAttempt, partialTokens: 0, ok: false, reason: 'empty-partial' }));
905
+ throw new StreamResumeExhaustedError('No partial content available to resume from.', { resumeAttempt, chunks: attemptChunks, partial: '' });
906
+ }
907
+ // Build the resume payload: assistant partial + user "continue".
908
+ workingMessages.push({ role: 'assistant', content: partial });
909
+ workingMessages.push({
910
+ role: 'user',
911
+ content: `[STREAM RESUMED] Your previous response was interrupted at ` +
912
+ `${attemptChunks.length} chunks. Continue exactly from where you left off. ` +
913
+ 'Do NOT repeat the partial content. Do NOT add preamble. ' +
914
+ 'Begin mid-sentence if needed.',
915
+ });
916
+ resumeAttempt += 1;
917
+ // Telemetry: this attempt succeeded; the next one is in flight.
918
+ recordTelemetry(telemetry.streamResume({
919
+ resumeAttempt,
920
+ partialTokens: Math.ceil(partial.length / 4),
921
+ ok: true,
922
+ }));
923
+ // Loop continues with the augmented message list.
924
+ }
925
+ }
926
+ }
927
+ async getEmbedding(text, model = 'text-embedding-3-small') {
928
+ const providerId = this.getProviderId(model);
929
+ providerCooldown.assertAvailable(providerId);
930
+ const direct = this.resolveDirectConfig(model);
931
+ let requestUrl = `${this.baseUrl}/embeddings`;
932
+ let headers = {
933
+ 'Content-Type': 'application/json',
934
+ 'Authorization': `Bearer ${this.apiKey}`,
935
+ };
936
+ if (direct) {
937
+ // Pillar 4: source the API key from the credential vault.
938
+ const vault = getProviderKeyVault();
939
+ requestUrl = `${direct.baseUrl}/embeddings`;
940
+ headers = await vault.withApiKey(direct.providerName, (key) => ({
941
+ 'Content-Type': 'application/json',
942
+ 'Authorization': `Bearer ${key}`,
943
+ }));
944
+ }
945
+ const body = JSON.stringify({
946
+ model,
947
+ input: text,
948
+ });
949
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
950
+ try {
951
+ const response = await fetch(requestUrl, {
952
+ method: 'POST',
953
+ headers,
954
+ body,
955
+ });
956
+ if (RETRYABLE_STATUS_CODES.has(response.status)) {
957
+ trackProviderError(providerId, response.status, `HTTP ${response.status}`);
958
+ const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
959
+ if (attempt < MAX_RETRIES) {
960
+ if (this.verbose) {
961
+ console.log(`${colors.yellow}⚠ [API] Embedding error ${response.status}. Retrying in ${(delayMs / 1000).toFixed(1)}s (${attempt + 1}/${MAX_RETRIES})${colors.reset}`);
962
+ }
963
+ await sleep(delayMs);
964
+ continue;
965
+ }
966
+ }
967
+ if (!response.ok) {
968
+ const errorText = await response.text().catch(() => 'Unknown error');
969
+ throw new Error(`API error (${response.status}): ${errorText}`);
970
+ }
971
+ const data = await response.json();
972
+ if (data.data && data.data[0] && data.data[0].embedding) {
973
+ providerCooldown.recordSuccess(providerId);
974
+ return data.data[0].embedding;
975
+ }
976
+ throw new Error('Malformed embedding response structure');
977
+ }
978
+ catch (error) {
979
+ if (attempt >= MAX_RETRIES)
980
+ throw error;
981
+ const isNetworkError = error instanceof Error && (error.name === 'TimeoutError' ||
982
+ error.message.includes('ECONNREFUSED') ||
983
+ error.message.includes('fetch failed') ||
984
+ error.message.includes('ETIMEDOUT'));
985
+ if (isNetworkError) {
986
+ trackProviderError(providerId, 0, error.message.slice(0, 200));
987
+ }
988
+ const delayMs = BASE_DELAY_MS * Math.pow(2, attempt);
989
+ await sleep(delayMs);
990
+ }
991
+ }
992
+ throw new Error('All embedding retry attempts exhausted.');
993
+ }
994
+ /* ─── Health probe ─── */
995
+ async ping() {
996
+ try {
997
+ const response = await fetch(`${this.baseUrl}/models`, {
998
+ headers: {
999
+ 'Authorization': `Bearer ${this.apiKey}`,
1000
+ },
1001
+ signal: AbortSignal.timeout(4000),
1002
+ });
1003
+ return response.ok;
1004
+ }
1005
+ catch {
1006
+ return false;
1007
+ }
1008
+ }
1009
+ }
1010
+ /* ──────────────────────── Helpers ──────────────────────── */
1011
+ function sleep(ms) {
1012
+ return new Promise(resolve => setTimeout(resolve, ms));
1013
+ }
1014
+ /* ──────────────────────── Translation Helpers ──────────────────────── */
1015
+ /**
1016
+ * Translate a `ChatMessage.content` value to the Anthropic `user`
1017
+ * content shape. Plain strings stay verbatim; block arrays are
1018
+ * mapped 1:1 with image blocks rewritten to Anthropic's `source`
1019
+ * sub-object.
1020
+ */
1021
+ function toAnthropicUserContent(content) {
1022
+ if (content == null)
1023
+ return '';
1024
+ if (typeof content === 'string')
1025
+ return content;
1026
+ return content.map((block) => {
1027
+ if (block.type === 'text')
1028
+ return { type: 'text', text: block.text };
1029
+ // image
1030
+ if (block.source.kind === 'base64') {
1031
+ return {
1032
+ type: 'image',
1033
+ source: {
1034
+ type: 'base64',
1035
+ media_type: block.source.mediaType,
1036
+ data: block.source.data,
1037
+ },
1038
+ };
1039
+ }
1040
+ // url — Anthropic supports url-shaped image sources as of 2024-06.
1041
+ return {
1042
+ type: 'image',
1043
+ source: { type: 'url', url: block.source.url },
1044
+ };
1045
+ });
1046
+ }
1047
+ /**
1048
+ * Translate a `ChatMessage.content` value to the OpenAI chat
1049
+ * completions `user` content shape (string OR a block array with
1050
+ * `image_url` blocks, per the OpenAI vision spec).
1051
+ */
1052
+ function toOpenAIUserContent(content) {
1053
+ if (content == null)
1054
+ return '';
1055
+ if (typeof content === 'string')
1056
+ return content;
1057
+ return content.map((block) => {
1058
+ if (block.type === 'text')
1059
+ return { type: 'text', text: block.text };
1060
+ if (block.source.kind === 'base64') {
1061
+ const dataUrl = `data:${block.source.mediaType};base64,${block.source.data}`;
1062
+ return { type: 'image_url', image_url: { url: dataUrl } };
1063
+ }
1064
+ return { type: 'image_url', image_url: { url: block.source.url } };
1065
+ });
1066
+ }
1067
+ /**
1068
+ * Rewrite a `ChatMessage[]` so every user message with content
1069
+ * blocks is translated to the OpenAI-vision wire shape. Messages
1070
+ * with plain-string content are returned untouched; assistant
1071
+ * and tool messages collapse to plain strings (those providers
1072
+ * never accept image blocks in those roles).
1073
+ *
1074
+ * The original array is never mutated; returned as `unknown[]`
1075
+ * because the OpenAI-vision wire shape is no longer assignable
1076
+ * to the strict `ChatMessage` union.
1077
+ */
1078
+ function messagesForOpenAIWire(messages) {
1079
+ let needsRewrite = false;
1080
+ for (const m of messages) {
1081
+ if (Array.isArray(m.content)) {
1082
+ needsRewrite = true;
1083
+ break;
1084
+ }
1085
+ }
1086
+ if (!needsRewrite)
1087
+ return messages;
1088
+ return messages.map((m) => {
1089
+ if (m.role === 'user') {
1090
+ return { ...m, content: toOpenAIUserContent(m.content) };
1091
+ }
1092
+ // Assistant / system / tool: collapse to text. We never send
1093
+ // images on those roles to OpenAI-compat endpoints.
1094
+ if (Array.isArray(m.content)) {
1095
+ return { ...m, content: extractTextFromContent(m.content) };
1096
+ }
1097
+ return m;
1098
+ });
1099
+ }
1100
+ function translateOpenAIToAnthropic(messages, model, options) {
1101
+ let system = '';
1102
+ const anthropicMessages = [];
1103
+ for (const msg of messages) {
1104
+ if (msg.role === 'system') {
1105
+ // System messages must be plain text. Image blocks on a
1106
+ // system message are nonsensical; we flatten defensively.
1107
+ const sysText = extractTextFromContent(msg.content);
1108
+ system = system ? `${system}\n${sysText}` : sysText;
1109
+ }
1110
+ else if (msg.role === 'user') {
1111
+ // User messages may carry image blocks. Translate the
1112
+ // OpenAI-shaped block array to Anthropic's native block
1113
+ // shape; plain strings continue to pass through verbatim.
1114
+ anthropicMessages.push({
1115
+ role: 'user',
1116
+ content: toAnthropicUserContent(msg.content),
1117
+ });
1118
+ }
1119
+ else if (msg.role === 'assistant') {
1120
+ const assistantText = extractTextFromContent(msg.content);
1121
+ if (msg.tool_calls && msg.tool_calls.length > 0) {
1122
+ const contentBlocks = [];
1123
+ if (assistantText.length > 0) {
1124
+ contentBlocks.push({ type: 'text', text: assistantText });
1125
+ }
1126
+ for (const tc of msg.tool_calls) {
1127
+ let inputObj = {};
1128
+ try {
1129
+ inputObj = JSON.parse(tc.function.arguments);
1130
+ }
1131
+ catch {
1132
+ inputObj = { raw: tc.function.arguments };
1133
+ }
1134
+ contentBlocks.push({
1135
+ type: 'tool_use',
1136
+ id: tc.id,
1137
+ name: tc.function.name,
1138
+ input: inputObj,
1139
+ });
1140
+ }
1141
+ anthropicMessages.push({
1142
+ role: 'assistant',
1143
+ content: contentBlocks,
1144
+ });
1145
+ }
1146
+ else {
1147
+ anthropicMessages.push({
1148
+ role: 'assistant',
1149
+ content: assistantText,
1150
+ });
1151
+ }
1152
+ }
1153
+ else if (msg.role === 'tool') {
1154
+ anthropicMessages.push({
1155
+ role: 'user',
1156
+ content: [
1157
+ {
1158
+ type: 'tool_result',
1159
+ tool_use_id: msg.tool_call_id,
1160
+ content: extractTextFromContent(msg.content),
1161
+ },
1162
+ ],
1163
+ });
1164
+ }
1165
+ }
1166
+ const body = {
1167
+ model,
1168
+ messages: anthropicMessages,
1169
+ max_tokens: options.max_tokens ?? 4096,
1170
+ };
1171
+ if (system) {
1172
+ body.system = system;
1173
+ }
1174
+ if (options.temperature !== undefined) {
1175
+ body.temperature = options.temperature;
1176
+ }
1177
+ if (options.tools && options.tools.length > 0) {
1178
+ body.tools = options.tools.map(t => ({
1179
+ name: t.function.name,
1180
+ description: t.function.description,
1181
+ input_schema: t.function.parameters,
1182
+ }));
1183
+ if (options.tool_choice) {
1184
+ if (options.tool_choice === 'auto' || options.tool_choice === 'none') {
1185
+ body.tool_choice = { type: options.tool_choice };
1186
+ }
1187
+ else if (typeof options.tool_choice === 'object' && options.tool_choice.function) {
1188
+ body.tool_choice = {
1189
+ type: 'any',
1190
+ name: options.tool_choice.function.name,
1191
+ };
1192
+ }
1193
+ }
1194
+ }
1195
+ return body;
1196
+ }
1197
+ function translateAnthropicToOpenAI(anthropicRes) {
1198
+ const contentBlocks = Array.isArray(anthropicRes.content) ? anthropicRes.content : [];
1199
+ let text = '';
1200
+ const toolCalls = [];
1201
+ for (const block of contentBlocks) {
1202
+ if (block.type === 'text') {
1203
+ text += block.text;
1204
+ }
1205
+ else if (block.type === 'tool_use') {
1206
+ toolCalls.push({
1207
+ id: block.id,
1208
+ type: 'function',
1209
+ function: {
1210
+ name: block.name,
1211
+ arguments: JSON.stringify(block.input),
1212
+ },
1213
+ });
1214
+ }
1215
+ }
1216
+ const finishReasonMap = {
1217
+ end_turn: 'stop',
1218
+ max_tokens: 'length',
1219
+ tool_use: 'tool_calls',
1220
+ stop_sequence: 'stop',
1221
+ };
1222
+ const choice = {
1223
+ index: 0,
1224
+ message: {
1225
+ role: 'assistant',
1226
+ content: text || null,
1227
+ },
1228
+ finish_reason: finishReasonMap[anthropicRes.stop_reason] || 'stop',
1229
+ };
1230
+ if (toolCalls.length > 0) {
1231
+ choice.message.tool_calls = toolCalls;
1232
+ }
1233
+ const usage = anthropicRes.usage ? {
1234
+ prompt_tokens: anthropicRes.usage.input_tokens || 0,
1235
+ completion_tokens: anthropicRes.usage.output_tokens || 0,
1236
+ total_tokens: (anthropicRes.usage.input_tokens || 0) + (anthropicRes.usage.output_tokens || 0),
1237
+ } : { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 };
1238
+ return {
1239
+ id: anthropicRes.id || `anthropic-${Date.now()}`,
1240
+ object: 'chat.completion',
1241
+ created: Math.floor(Date.now() / 1000),
1242
+ model: anthropicRes.model || '',
1243
+ choices: [choice],
1244
+ usage,
1245
+ };
1246
+ }
1247
+ //# sourceMappingURL=agent-client.js.map