usage.test.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. import { describe, expect, test } from "bun:test"
  2. import type { SessionNotification } from "@agentclientprotocol/sdk"
  3. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  4. import { ProviderV2 } from "@kirincode-ai/core/provider"
  5. import { ModelV2 } from "@kirincode-ai/core/model"
  6. import { UsageService } from "@/acp/usage"
  7. import { Provider } from "@/provider/provider"
  8. import { Effect, Layer } from "effect"
  9. import { it } from "../lib/effect"
  10. const assistant = (
  11. input: Partial<UsageService.AssistantMessage> & Pick<UsageService.AssistantMessage, "cost">,
  12. ): UsageService.SessionMessage => ({
  13. info: {
  14. role: "assistant",
  15. providerID: "anthropic",
  16. modelID: "claude-sonnet",
  17. tokens: {
  18. input: 10,
  19. output: 20,
  20. reasoning: 0,
  21. cache: { read: 0, write: 0 },
  22. },
  23. ...input,
  24. },
  25. })
  26. const user = (): UsageService.SessionMessage => ({
  27. info: { role: "user" },
  28. })
  29. const assistantWithoutProvider = (): UsageService.SessionMessage => ({
  30. info: {
  31. role: "assistant",
  32. modelID: "claude-sonnet",
  33. cost: 1,
  34. tokens: {
  35. input: 10,
  36. output: 20,
  37. reasoning: 0,
  38. cache: { read: 0, write: 0 },
  39. },
  40. },
  41. })
  42. const model = (providerID: ProviderV2.ID, modelID: ModelV2.ID, context: number): Provider.Model => ({
  43. id: modelID,
  44. providerID,
  45. api: {
  46. id: modelID,
  47. url: "https://example.com",
  48. npm: "@ai-sdk/openai-compatible",
  49. },
  50. name: modelID,
  51. family: "test",
  52. capabilities: {
  53. temperature: true,
  54. reasoning: false,
  55. attachment: false,
  56. toolcall: true,
  57. input: { text: true, audio: false, image: false, video: false, pdf: false },
  58. output: { text: true, audio: false, image: false, video: false, pdf: false },
  59. interleaved: false,
  60. },
  61. cost: {
  62. input: 0,
  63. output: 0,
  64. cache: { read: 0, write: 0 },
  65. },
  66. limit: {
  67. context,
  68. output: 4096,
  69. },
  70. status: "active",
  71. options: {},
  72. headers: {},
  73. release_date: "2026-01-01",
  74. })
  75. const providers = (context = 128_000): Record<ProviderV2.ID, Provider.Info> => {
  76. const providerID = ProviderV2.ID.make("anthropic")
  77. const modelID = ModelV2.ID.make("claude-sonnet")
  78. return {
  79. [providerID]: {
  80. id: providerID,
  81. name: "Anthropic",
  82. source: "config",
  83. env: [],
  84. options: {},
  85. models: {
  86. [modelID]: model(providerID, modelID, context),
  87. },
  88. },
  89. }
  90. }
  91. const fakeLayer = (input: {
  92. readonly messages?: Effect.Effect<readonly UsageService.SessionMessage[], unknown>
  93. readonly providers?: (directory: string) => Effect.Effect<Record<ProviderV2.ID, Provider.Info>, unknown>
  94. }) =>
  95. LayerNode.compile(UsageService.node, [
  96. [
  97. UsageService.messageLoaderNode,
  98. Layer.succeed(
  99. UsageService.MessageLoader,
  100. UsageService.MessageLoader.of({
  101. messages: () => input.messages ?? Effect.succeed([]),
  102. }),
  103. ),
  104. ],
  105. [
  106. UsageService.contextLimitLoaderNode,
  107. Layer.succeed(
  108. UsageService.ContextLimitLoader,
  109. UsageService.ContextLimitLoader.of({
  110. providers: input.providers ?? (() => Effect.succeed(providers())),
  111. }),
  112. ),
  113. ],
  114. ])
  115. const connection = (updates: SessionNotification[]) => ({
  116. sessionUpdate(params: SessionNotification) {
  117. updates.push(params)
  118. return Promise.resolve()
  119. },
  120. })
  121. describe("acp usage", () => {
  122. test("builds ACP Usage from assistant token shape", () => {
  123. expect(
  124. UsageService.buildUsage({
  125. cost: 0.02,
  126. tokens: {
  127. input: 100,
  128. output: 40,
  129. reasoning: 7,
  130. cache: { read: 11, write: 13 },
  131. },
  132. }),
  133. ).toEqual({
  134. inputTokens: 100,
  135. outputTokens: 40,
  136. thoughtTokens: 7,
  137. cachedReadTokens: 11,
  138. cachedWriteTokens: 13,
  139. totalTokens: 171,
  140. })
  141. })
  142. test("omits optional token fields when they are zero", () => {
  143. expect(
  144. UsageService.buildUsage({
  145. cost: 0,
  146. tokens: {
  147. input: 3,
  148. output: 4,
  149. reasoning: 0,
  150. cache: { read: 0, write: 0 },
  151. },
  152. }),
  153. ).toEqual({
  154. inputTokens: 3,
  155. outputTokens: 4,
  156. totalTokens: 7,
  157. })
  158. })
  159. test("finds the latest assistant message", () => {
  160. expect(
  161. UsageService.latestAssistantMessage([assistant({ cost: 1, modelID: "older" }), user(), assistant({ cost: 2 })]),
  162. ).toMatchObject({ cost: 2 })
  163. })
  164. test("calculates total session cost from assistant messages", () => {
  165. expect(UsageService.totalSessionCost([assistant({ cost: 1.25 }), user(), assistant({ cost: 2.5 })])).toBe(3.75)
  166. })
  167. it.effect("loads context limits from providers and caches by directory/provider/model", () => {
  168. const calls: string[] = []
  169. return Effect.gen(function* () {
  170. const usage = yield* UsageService.Service
  171. const first = yield* usage.contextLimit({
  172. directory: "/workspace",
  173. providerID: ProviderV2.ID.make("anthropic"),
  174. modelID: ModelV2.ID.make("claude-sonnet"),
  175. })
  176. const second = yield* usage.contextLimit({
  177. directory: "/workspace",
  178. providerID: ProviderV2.ID.make("anthropic"),
  179. modelID: ModelV2.ID.make("claude-sonnet"),
  180. })
  181. expect(first).toBe(200_000)
  182. expect(second).toBe(200_000)
  183. expect(calls).toEqual(["/workspace"])
  184. }).pipe(
  185. Effect.provide(
  186. fakeLayer({
  187. providers: (directory) =>
  188. Effect.sync(() => {
  189. calls.push(directory)
  190. return providers(200_000)
  191. }),
  192. }),
  193. ),
  194. )
  195. })
  196. it.effect("sends ACP usage_update with context size and cumulative assistant cost", () => {
  197. const updates: SessionNotification[] = []
  198. return Effect.gen(function* () {
  199. const usage = yield* UsageService.Service
  200. yield* usage.sendUpdate({
  201. connection: connection(updates),
  202. sessionID: "ses_1",
  203. directory: "/workspace",
  204. })
  205. expect(updates).toEqual([
  206. {
  207. sessionId: "ses_1",
  208. update: {
  209. sessionUpdate: "usage_update",
  210. used: 15,
  211. size: 128_000,
  212. cost: { amount: 3, currency: "USD" },
  213. },
  214. },
  215. ])
  216. }).pipe(
  217. Effect.provide(
  218. fakeLayer({
  219. messages: Effect.succeed([
  220. assistant({ cost: 1 }),
  221. assistant({
  222. cost: 2,
  223. tokens: {
  224. input: 10,
  225. output: 20,
  226. reasoning: 0,
  227. cache: { read: 5, write: 0 },
  228. },
  229. }),
  230. ]),
  231. }),
  232. ),
  233. )
  234. })
  235. it.effect("skips usage update when messages cannot be fetched", () => {
  236. const updates: SessionNotification[] = []
  237. return Effect.gen(function* () {
  238. const usage = yield* UsageService.Service
  239. yield* usage.sendUpdate({
  240. connection: connection(updates),
  241. sessionID: "ses_1",
  242. directory: "/workspace",
  243. })
  244. expect(updates).toEqual([])
  245. }).pipe(Effect.provide(fakeLayer({ messages: Effect.fail(new Error("boom")) })))
  246. })
  247. it.effect("skips usage update when no assistant message exists", () => {
  248. const updates: SessionNotification[] = []
  249. return Effect.gen(function* () {
  250. const usage = yield* UsageService.Service
  251. yield* usage.sendUpdate({
  252. connection: connection(updates),
  253. sessionID: "ses_1",
  254. directory: "/workspace",
  255. })
  256. expect(updates).toEqual([])
  257. }).pipe(Effect.provide(fakeLayer({ messages: Effect.succeed([user()]) })))
  258. })
  259. it.effect("skips usage update when assistant message has no provider or model", () => {
  260. const updates: SessionNotification[] = []
  261. return Effect.gen(function* () {
  262. const usage = yield* UsageService.Service
  263. yield* usage.sendUpdate({
  264. connection: connection(updates),
  265. sessionID: "ses_1",
  266. directory: "/workspace",
  267. })
  268. expect(updates).toEqual([])
  269. }).pipe(
  270. Effect.provide(
  271. fakeLayer({
  272. messages: Effect.succeed([assistantWithoutProvider()]),
  273. }),
  274. ),
  275. )
  276. })
  277. it.effect("skips usage update when context size is unknown", () => {
  278. const updates: SessionNotification[] = []
  279. return Effect.gen(function* () {
  280. const usage = yield* UsageService.Service
  281. yield* usage.sendUpdate({
  282. connection: connection(updates),
  283. sessionID: "ses_1",
  284. directory: "/workspace",
  285. })
  286. expect(updates).toEqual([])
  287. }).pipe(
  288. Effect.provide(
  289. fakeLayer({
  290. messages: Effect.succeed([assistant({ cost: 1, providerID: "missing" })]),
  291. }),
  292. ),
  293. )
  294. })
  295. })