tool-schema-projection.test.ts 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect } from "effect"
  3. import { LLM } from "../src"
  4. import { OpenAIChat } from "../src/protocols"
  5. import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema"
  6. import { Auth, LLMClient } from "../src/route"
  7. import { it } from "./lib/effect"
  8. describe("tool schema projections", () => {
  9. test("moonshot strips $ref siblings and converts tuple arrays to a schema object", () => {
  10. expect(
  11. ToolSchemaProjection.moonshot({
  12. type: "object",
  13. properties: {
  14. linked: { $ref: "#/$defs/Linked", description: "drop me" },
  15. tuple: { type: "array", items: [{ type: "string" }, { type: "number" }] },
  16. prefixTuple: { type: "array", prefixItems: [{ type: "boolean" }, { type: "string" }] },
  17. },
  18. }),
  19. ).toEqual({
  20. type: "object",
  21. properties: {
  22. linked: { $ref: "#/$defs/Linked" },
  23. tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
  24. prefixTuple: { type: "array", items: { anyOf: [{ type: "boolean" }, { type: "string" }] } },
  25. },
  26. })
  27. })
  28. test("gemini handles numeric enums, dangling required fields, untyped arrays, and scalar object keys", () => {
  29. expect(
  30. ToolSchemaProjection.gemini({
  31. type: "object",
  32. required: ["status", "missing"],
  33. properties: {
  34. status: { type: "integer", enum: [1, 2] },
  35. tags: { type: "array" },
  36. name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] },
  37. },
  38. }),
  39. ).toEqual({
  40. type: "object",
  41. required: ["status"],
  42. properties: {
  43. status: { type: "string", enum: ["1", "2"] },
  44. tags: { type: "array", items: { type: "string" } },
  45. name: { type: "string" },
  46. },
  47. })
  48. })
  49. test("openai keeps one flat object top-level schema", () => {
  50. expect(
  51. ToolSchemaProjection.openAI({
  52. anyOf: [
  53. {
  54. type: "object",
  55. properties: {
  56. path: { type: "string" },
  57. maybe: { anyOf: [{ type: "string" }, { type: "null" }] },
  58. },
  59. },
  60. { type: "object", properties: { resource: { type: "string" } } },
  61. ],
  62. }),
  63. ).toEqual({
  64. type: "object",
  65. properties: {
  66. path: { type: "string" },
  67. maybe: { type: "string" },
  68. resource: { type: "string" },
  69. },
  70. additionalProperties: false,
  71. })
  72. })
  73. it.effect("applies model compatibility before protocol projection", () =>
  74. Effect.gen(function* () {
  75. const model = OpenAIChat.route
  76. .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
  77. .model({ id: "kimi-k2", compatibility: { toolSchema: "moonshot" } })
  78. const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
  79. LLM.request({
  80. model,
  81. prompt: "Use the tool.",
  82. tools: [
  83. {
  84. name: "lookup",
  85. description: "Lookup data.",
  86. inputSchema: {
  87. type: "object",
  88. anyOf: [
  89. {
  90. type: "object",
  91. properties: {
  92. tuple: { type: "array", items: [{ type: "string" }, { type: "number" }] },
  93. linked: { $ref: "#/$defs/Linked", description: "drop me" },
  94. },
  95. },
  96. ],
  97. },
  98. },
  99. ],
  100. }),
  101. )
  102. expect(prepared.body.tools?.[0]?.function.parameters).toEqual({
  103. type: "object",
  104. properties: {
  105. tuple: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } },
  106. linked: { $ref: "#/$defs/Linked" },
  107. },
  108. additionalProperties: false,
  109. })
  110. }),
  111. )
  112. })