tool-schema-projection.test.ts 3.8 KB

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