openai-responses-language-model.test.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. import { OpenAIResponsesLanguageModel } from "@opencode-ai/core/github-copilot/responses/openai-responses-language-model"
  2. import { convertToOpenAIResponsesInput } from "@opencode-ai/core/github-copilot/responses/convert-to-openai-responses-input"
  3. import { describe, test, expect, mock } from "bun:test"
  4. import type { LanguageModelV3Prompt } from "@ai-sdk/provider"
  5. const TEST_PROMPT: LanguageModelV3Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }]
  6. function createMockFetch(body: unknown) {
  7. return mock(
  8. async () => new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }),
  9. )
  10. }
  11. function createModel(fetchFn: ReturnType<typeof mock>) {
  12. return new OpenAIResponsesLanguageModel("test-model", {
  13. provider: "copilot",
  14. url: () => "https://api.test.com/responses",
  15. headers: () => ({ Authorization: "Bearer test-token" }),
  16. fetch: fetchFn as any,
  17. })
  18. }
  19. // GitHub Copilot's Responses model echoes item metadata (itemId, reasoningEncryptedContent,
  20. // responseId, ...) under the "copilot" providerOptions/providerMetadata namespace, matching the
  21. // namespace request options already use. It used to echo this metadata under "openai" (a leftover
  22. // from forking the OpenAI Responses model), which left it unreachable by anything reading the
  23. // "copilot" namespace and let stale itemIds slip past stripping meant for that namespace.
  24. describe("doGenerate", () => {
  25. test("attaches item metadata under the copilot namespace, not openai", async () => {
  26. const mockFetch = createMockFetch({
  27. id: "resp_1",
  28. created_at: 0,
  29. model: "gpt-5.5",
  30. output: [
  31. {
  32. type: "reasoning",
  33. id: "rs_1",
  34. encrypted_content: "enc_1",
  35. summary: [{ type: "summary_text", text: "thinking..." }],
  36. },
  37. {
  38. type: "message",
  39. role: "assistant",
  40. id: "msg_1",
  41. content: [{ type: "output_text", text: "Hello there", annotations: [] }],
  42. },
  43. {
  44. type: "function_call",
  45. call_id: "call_1",
  46. name: "bash",
  47. arguments: "{}",
  48. id: "fc_1",
  49. },
  50. ],
  51. usage: { input_tokens: 10, output_tokens: 5 },
  52. })
  53. const model = createModel(mockFetch)
  54. const { content, providerMetadata } = await model.doGenerate({
  55. prompt: TEST_PROMPT,
  56. includeRawChunks: false,
  57. } as any)
  58. const reasoning = content.find((part: any) => part.type === "reasoning") as any
  59. expect(reasoning.providerMetadata?.copilot?.itemId).toBe("rs_1")
  60. expect(reasoning.providerMetadata?.copilot?.reasoningEncryptedContent).toBe("enc_1")
  61. expect(reasoning.providerMetadata?.openai).toBeUndefined()
  62. const text = content.find((part: any) => part.type === "text") as any
  63. expect(text.providerMetadata?.copilot?.itemId).toBe("msg_1")
  64. expect(text.providerMetadata?.openai).toBeUndefined()
  65. const toolCall = content.find((part: any) => part.type === "tool-call") as any
  66. expect(toolCall.providerMetadata?.copilot?.itemId).toBe("fc_1")
  67. expect(toolCall.providerMetadata?.openai).toBeUndefined()
  68. expect(providerMetadata?.copilot?.responseId).toBe("resp_1")
  69. expect(providerMetadata?.openai).toBeUndefined()
  70. })
  71. })
  72. describe("convertToOpenAIResponsesInput", () => {
  73. test("echoes a stale tool-call itemId from the copilot namespace as the function_call id", async () => {
  74. const { input } = await convertToOpenAIResponsesInput({
  75. prompt: [
  76. {
  77. role: "assistant",
  78. content: [
  79. {
  80. type: "tool-call",
  81. toolCallId: "call_1",
  82. toolName: "bash",
  83. input: { command: "ls" },
  84. providerOptions: { copilot: { itemId: "fc_999" } },
  85. },
  86. ],
  87. },
  88. ],
  89. systemMessageMode: "system",
  90. store: false,
  91. })
  92. expect(input).toEqual([
  93. {
  94. type: "function_call",
  95. call_id: "call_1",
  96. name: "bash",
  97. arguments: JSON.stringify({ command: "ls" }),
  98. id: "fc_999",
  99. },
  100. ])
  101. })
  102. test("omits the function_call id once the stale copilot itemId has been stripped", async () => {
  103. const { input } = await convertToOpenAIResponsesInput({
  104. prompt: [
  105. {
  106. role: "assistant",
  107. content: [
  108. {
  109. type: "tool-call",
  110. toolCallId: "call_1",
  111. toolName: "bash",
  112. input: { command: "ls" },
  113. providerOptions: {},
  114. },
  115. ],
  116. },
  117. ],
  118. systemMessageMode: "system",
  119. store: false,
  120. })
  121. expect((input[0] as any).id).toBeUndefined()
  122. })
  123. test("preserves reasoning items keyed by the copilot namespace instead of dropping them", async () => {
  124. const { input, warnings } = await convertToOpenAIResponsesInput({
  125. prompt: [
  126. {
  127. role: "assistant",
  128. content: [
  129. {
  130. type: "reasoning",
  131. text: "thinking...",
  132. providerOptions: { copilot: { itemId: "rs_1", reasoningEncryptedContent: "enc_1" } },
  133. },
  134. ],
  135. },
  136. ],
  137. systemMessageMode: "system",
  138. store: false,
  139. })
  140. expect(warnings).toEqual([])
  141. expect(input).toEqual([
  142. {
  143. type: "reasoning",
  144. id: "rs_1",
  145. encrypted_content: "enc_1",
  146. summary: [{ type: "summary_text", text: "thinking..." }],
  147. },
  148. ])
  149. })
  150. test("drops reasoning items with no copilot itemId and warns, as before", async () => {
  151. const { input, warnings } = await convertToOpenAIResponsesInput({
  152. prompt: [
  153. {
  154. role: "assistant",
  155. content: [{ type: "reasoning", text: "thinking...", providerOptions: {} }],
  156. },
  157. ],
  158. systemMessageMode: "system",
  159. store: false,
  160. })
  161. expect(input).toEqual([])
  162. expect(warnings).toHaveLength(1)
  163. expect(warnings[0]).toMatchObject({
  164. message: expect.stringContaining("Non-OpenAI reasoning parts are not supported"),
  165. })
  166. })
  167. test("reads imageDetail from the copilot namespace on user file parts", async () => {
  168. const { input } = await convertToOpenAIResponsesInput({
  169. prompt: [
  170. {
  171. role: "user",
  172. content: [
  173. {
  174. type: "file",
  175. mediaType: "image/png",
  176. data: "aGVsbG8=",
  177. providerOptions: { copilot: { imageDetail: "high" } },
  178. },
  179. ],
  180. },
  181. ],
  182. systemMessageMode: "system",
  183. store: false,
  184. })
  185. expect((input[0] as any).content[0].detail).toBe("high")
  186. })
  187. })