schema.test.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect, Schema } from "effect"
  3. import * as OpenAIChat from "../src/protocols/openai-chat.js"
  4. import * as OpenAIResponses from "../src/protocols/openai-responses.js"
  5. import {
  6. AIError,
  7. ContentPart,
  8. InvalidRequestReason,
  9. LLMEvent,
  10. LLMRequest,
  11. LanguageModel,
  12. ModelID,
  13. ProviderID,
  14. TransportReason,
  15. Usage,
  16. } from "../src/schema/index.js"
  17. import { ProviderShared } from "../src/protocols/shared.js"
  18. const model = new LanguageModel({
  19. id: ModelID.make("fake-model"),
  20. provider: ProviderID.make("fake-provider"),
  21. route: OpenAIChat.route,
  22. })
  23. const decodeLLMRequest = Schema.decodeUnknownSync(LLMRequest as unknown as Schema.Decoder<LLMRequest>)
  24. const decodeLLMEvent = Schema.decodeUnknownSync(LLMEvent as unknown as Schema.Decoder<LLMEvent>)
  25. describe("llm schema", () => {
  26. test("decodes a minimal request", () => {
  27. const input: unknown = {
  28. id: "req_1",
  29. model,
  30. system: [{ type: "text", text: "You are terse." }],
  31. messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
  32. tools: [],
  33. generation: {},
  34. }
  35. const decoded = decodeLLMRequest(input)
  36. expect(decoded.id).toBe("req_1")
  37. expect(decoded.messages[0]?.content[0]?.type).toBe("text")
  38. })
  39. test("accepts custom route ids", () => {
  40. const decoded = decodeLLMRequest({
  41. model: LanguageModel.update(model, { route: OpenAIResponses.route }),
  42. system: [],
  43. messages: [],
  44. tools: [],
  45. generation: {},
  46. })
  47. expect(decoded.model.route.id).toBe("openai-responses")
  48. })
  49. test("rejects invalid event type", () => {
  50. expect(() => decodeLLMEvent({ type: "bogus" })).toThrow()
  51. })
  52. test("finish constructors accept usage input", () => {
  53. expect(
  54. LLMEvent.stepFinish({ index: 0, reason: { normalized: "stop" }, usage: { inputTokens: 1 } }).usage,
  55. ).toBeInstanceOf(Usage)
  56. expect(LLMEvent.finish({ reason: { normalized: "stop" }, usage: { outputTokens: 2 } }).usage).toBeInstanceOf(Usage)
  57. })
  58. test("content part tagged union exposes guards", () => {
  59. expect(ContentPart.guards.text({ type: "text", text: "hi" })).toBe(true)
  60. expect(ContentPart.guards.media({ type: "text", text: "hi" })).toBe(false)
  61. })
  62. })
  63. describe("AI.Usage", () => {
  64. test("subtractTokens clamps non-sensical breakdowns to zero", () => {
  65. // Defense against a provider reporting cached_tokens > prompt_tokens or
  66. // reasoning_tokens > completion_tokens — the negative would otherwise
  67. // round-trip through the pipeline and crash strict downstream schemas.
  68. expect(ProviderShared.subtractTokens(5, 3)).toBe(2)
  69. expect(ProviderShared.subtractTokens(5, 10)).toBe(0)
  70. expect(ProviderShared.subtractTokens(5, undefined)).toBe(5)
  71. expect(ProviderShared.subtractTokens(undefined, 3)).toBeUndefined()
  72. expect(ProviderShared.subtractTokens(undefined, undefined)).toBeUndefined()
  73. })
  74. test("sumTokens returns undefined only when every input is undefined", () => {
  75. expect(ProviderShared.sumTokens(1, 2, 3)).toBe(6)
  76. expect(ProviderShared.sumTokens(1, undefined, 3)).toBe(4)
  77. expect(ProviderShared.sumTokens(undefined, undefined, undefined)).toBeUndefined()
  78. expect(ProviderShared.sumTokens()).toBeUndefined()
  79. })
  80. test("visibleOutputTokens clamps reasoning > output to zero", () => {
  81. expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
  82. expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)
  83. expect(new Usage({ outputTokens: 4, reasoningTokens: 10 }).visibleOutputTokens).toBe(0)
  84. expect(new Usage({}).visibleOutputTokens).toBe(0)
  85. })
  86. })
  87. test("AI errors expose the shared runtime tag", async () => {
  88. const error = new AIError({
  89. module: "test",
  90. method: "call",
  91. reason: new InvalidRequestReason({ message: "invalid" }),
  92. })
  93. expect(error._tag).toBe("AI.Error")
  94. expect(
  95. await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
  96. ).toBe("caught")
  97. })
  98. test("transport errors serialize execution facts", () => {
  99. const reason = new TransportReason({
  100. message: "connection closed",
  101. transport: "websocket",
  102. operation: "read",
  103. phase: "receive",
  104. delivery: "ambiguous",
  105. recovery: "fail",
  106. })
  107. expect(Schema.encodeSync(TransportReason)(reason)).toEqual({
  108. _tag: "Transport",
  109. message: "connection closed",
  110. transport: "websocket",
  111. operation: "read",
  112. phase: "receive",
  113. delivery: "ambiguous",
  114. recovery: "fail",
  115. })
  116. expect(Schema.decodeUnknownSync(TransportReason)(Schema.encodeSync(TransportReason)(reason))).toEqual(reason)
  117. })