1
0

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

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  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, LanguageModelV3StreamPart } 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 createStreamFetch(events: ReadonlyArray<Record<string, unknown>>) {
  12. return mock(
  13. async () =>
  14. new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
  15. status: 200,
  16. headers: { "Content-Type": "text/event-stream" },
  17. }),
  18. )
  19. }
  20. function createModel(fetchFn: ReturnType<typeof mock>) {
  21. return new OpenAIResponsesLanguageModel("test-model", {
  22. provider: "copilot",
  23. url: () => "https://api.test.com/responses",
  24. headers: () => ({ Authorization: "Bearer test-token" }),
  25. fetch: fetchFn as any,
  26. })
  27. }
  28. // GitHub Copilot's Responses model echoes item metadata (itemId, reasoningEncryptedContent,
  29. // responseId, ...) under the "copilot" providerOptions/providerMetadata namespace, matching the
  30. // namespace request options already use. It used to echo this metadata under "openai" (a leftover
  31. // from forking the OpenAI Responses model), which left it unreachable by anything reading the
  32. // "copilot" namespace and let stale itemIds slip past stripping meant for that namespace.
  33. describe("doGenerate", () => {
  34. test("attaches item metadata under the copilot namespace, not openai", async () => {
  35. const mockFetch = createMockFetch({
  36. id: "resp_1",
  37. created_at: 0,
  38. model: "gpt-5.5",
  39. output: [
  40. {
  41. type: "reasoning",
  42. id: "rs_1",
  43. encrypted_content: "enc_1",
  44. summary: [{ type: "summary_text", text: "thinking..." }],
  45. },
  46. {
  47. type: "message",
  48. role: "assistant",
  49. id: "msg_1",
  50. content: [{ type: "output_text", text: "Hello there", annotations: [] }],
  51. },
  52. {
  53. type: "function_call",
  54. call_id: "call_1",
  55. name: "bash",
  56. arguments: "{}",
  57. id: "fc_1",
  58. },
  59. ],
  60. usage: { input_tokens: 10, output_tokens: 5 },
  61. })
  62. const model = createModel(mockFetch)
  63. const { content, providerMetadata } = await model.doGenerate({
  64. prompt: TEST_PROMPT,
  65. includeRawChunks: false,
  66. } as any)
  67. const reasoning = content.find((part: any) => part.type === "reasoning") as any
  68. expect(reasoning.providerMetadata?.copilot?.itemId).toBe("rs_1")
  69. expect(reasoning.providerMetadata?.copilot?.reasoningEncryptedContent).toBe("enc_1")
  70. expect(reasoning.providerMetadata?.openai).toBeUndefined()
  71. const text = content.find((part: any) => part.type === "text") as any
  72. expect(text.providerMetadata?.copilot?.itemId).toBe("msg_1")
  73. expect(text.providerMetadata?.openai).toBeUndefined()
  74. const toolCall = content.find((part: any) => part.type === "tool-call") as any
  75. expect(toolCall.providerMetadata?.copilot?.itemId).toBe("fc_1")
  76. expect(toolCall.providerMetadata?.openai).toBeUndefined()
  77. expect(providerMetadata?.copilot?.responseId).toBe("resp_1")
  78. expect(providerMetadata?.openai).toBeUndefined()
  79. })
  80. test("defaults to stateless encrypted reasoning and keeps previousResponseId opt-in", async () => {
  81. const requests: Array<Record<string, unknown>> = []
  82. const fetchFn = mock(async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
  83. requests.push(JSON.parse(init?.body as string))
  84. return new Response(
  85. JSON.stringify({
  86. id: "resp_1",
  87. created_at: 0,
  88. model: "gpt-5.5",
  89. output: [],
  90. usage: { input_tokens: 1, output_tokens: 1 },
  91. }),
  92. { status: 200, headers: { "Content-Type": "application/json" } },
  93. )
  94. })
  95. const model = createModel(fetchFn)
  96. await model.doGenerate({ prompt: TEST_PROMPT, includeRawChunks: false } as any)
  97. await model.doGenerate({
  98. prompt: TEST_PROMPT,
  99. includeRawChunks: false,
  100. providerOptions: { copilot: { previousResponseId: "resp_previous", store: false } },
  101. } as any)
  102. await model.doGenerate({
  103. prompt: TEST_PROMPT,
  104. includeRawChunks: false,
  105. providerOptions: { copilot: { store: true } },
  106. } as any)
  107. expect(requests[0]?.previous_response_id).toBeUndefined()
  108. expect(requests[0]?.store).toBe(false)
  109. expect(requests[0]?.include).toEqual(["reasoning.encrypted_content"])
  110. expect(requests[1]?.previous_response_id).toBe("resp_previous")
  111. expect(requests[1]?.store).toBe(false)
  112. expect(requests[1]?.include).toEqual(["reasoning.encrypted_content"])
  113. expect(requests[2]?.store).toBe(true)
  114. expect(requests[2]?.include).toEqual(["reasoning.encrypted_content"])
  115. })
  116. })
  117. describe("doStream", () => {
  118. test("streams sequential Copilot reasoning summary blocks", async () => {
  119. const model = createModel(
  120. createStreamFetch([
  121. {
  122. type: "response.output_item.added",
  123. output_index: 0,
  124. item: { type: "reasoning", id: "rs_1", encrypted_content: null },
  125. },
  126. {
  127. type: "response.output_item.added",
  128. output_index: 0,
  129. item: { type: "reasoning", id: "rs_rotated", encrypted_content: null },
  130. },
  131. { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
  132. { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
  133. { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
  134. { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
  135. { type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
  136. { type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
  137. { type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
  138. {
  139. type: "response.output_item.done",
  140. output_index: 0,
  141. item: { type: "reasoning", id: "rs_rotated", encrypted_content: "encrypted-state" },
  142. },
  143. ]),
  144. )
  145. const result = await model.doStream({
  146. prompt: TEST_PROMPT,
  147. includeRawChunks: false,
  148. providerOptions: { copilot: { store: false } },
  149. } as any)
  150. const reader = result.stream.getReader()
  151. const events: LanguageModelV3StreamPart[] = []
  152. while (true) {
  153. const item = await reader.read()
  154. if (item.done) break
  155. if (item.value.type.startsWith("reasoning-")) events.push(item.value)
  156. }
  157. expect(events).toMatchObject([
  158. {
  159. type: "reasoning-start",
  160. id: "rs_1:0",
  161. providerMetadata: { copilot: { itemId: "rs_1", reasoningEncryptedContent: null } },
  162. },
  163. { type: "reasoning-delta", id: "rs_1:0", delta: "First" },
  164. { type: "reasoning-end", id: "rs_1:0", providerMetadata: { copilot: { itemId: "rs_1" } } },
  165. {
  166. type: "reasoning-start",
  167. id: "rs_1:1",
  168. providerMetadata: { copilot: { itemId: "rs_1", reasoningEncryptedContent: null } },
  169. },
  170. { type: "reasoning-delta", id: "rs_1:1", delta: "Second" },
  171. {
  172. type: "reasoning-end",
  173. id: "rs_1:1",
  174. providerMetadata: { copilot: { itemId: "rs_rotated", reasoningEncryptedContent: "encrypted-state" } },
  175. },
  176. ])
  177. const deltas = new Map(
  178. events.filter((event) => event.type === "reasoning-delta").map((event) => [event.id, event.delta] as const),
  179. )
  180. const { input } = await convertToOpenAIResponsesInput({
  181. prompt: [
  182. {
  183. role: "assistant",
  184. content: events
  185. .filter((event) => event.type === "reasoning-end")
  186. .map((event) => ({
  187. type: "reasoning" as const,
  188. text: deltas.get(event.id) ?? "",
  189. providerOptions: event.providerMetadata,
  190. })),
  191. },
  192. ],
  193. systemMessageMode: "system",
  194. store: false,
  195. })
  196. expect(input).toEqual([
  197. {
  198. type: "reasoning",
  199. id: "rs_rotated",
  200. encrypted_content: "encrypted-state",
  201. summary: [],
  202. },
  203. ])
  204. })
  205. test("closes reasoning when a Copilot stream ends before output_item.done", async () => {
  206. const model = createModel(
  207. createStreamFetch([
  208. {
  209. type: "response.output_item.added",
  210. output_index: 0,
  211. item: { type: "reasoning", id: "rs_1", encrypted_content: null },
  212. },
  213. { type: "response.reasoning_summary_text.delta", item_id: "rs_rotated", summary_index: 0, delta: "First" },
  214. ]),
  215. )
  216. const result = await model.doStream({
  217. prompt: TEST_PROMPT,
  218. includeRawChunks: false,
  219. providerOptions: { copilot: { store: false } },
  220. } as any)
  221. const reader = result.stream.getReader()
  222. const events: LanguageModelV3StreamPart[] = []
  223. while (true) {
  224. const item = await reader.read()
  225. if (item.done) break
  226. if (item.value.type.startsWith("reasoning-")) events.push(item.value)
  227. }
  228. expect(events.map((event) => event.type)).toEqual(["reasoning-start", "reasoning-delta", "reasoning-end"])
  229. expect(events.at(-1)).toMatchObject({
  230. type: "reasoning-end",
  231. id: "rs_1:0",
  232. providerMetadata: { copilot: { itemId: "rs_1" } },
  233. })
  234. })
  235. })
  236. describe("convertToOpenAIResponsesInput", () => {
  237. test("omits response item IDs from stateless function calls", async () => {
  238. const { input } = await convertToOpenAIResponsesInput({
  239. prompt: [
  240. {
  241. role: "assistant",
  242. content: [
  243. {
  244. type: "tool-call",
  245. toolCallId: "call_1",
  246. toolName: "bash",
  247. input: { command: "ls" },
  248. providerOptions: { copilot: { itemId: "fc_999" } },
  249. },
  250. ],
  251. },
  252. ],
  253. systemMessageMode: "system",
  254. store: false,
  255. })
  256. expect(input).toEqual([
  257. {
  258. type: "function_call",
  259. call_id: "call_1",
  260. name: "bash",
  261. arguments: JSON.stringify({ command: "ls" }),
  262. },
  263. ])
  264. })
  265. test("preserves response item IDs for stored function calls", async () => {
  266. const { input } = await convertToOpenAIResponsesInput({
  267. prompt: [
  268. {
  269. role: "assistant",
  270. content: [
  271. {
  272. type: "tool-call",
  273. toolCallId: "call_1",
  274. toolName: "bash",
  275. input: { command: "ls" },
  276. providerOptions: { copilot: { itemId: "fc_999" } },
  277. },
  278. ],
  279. },
  280. ],
  281. systemMessageMode: "system",
  282. store: true,
  283. })
  284. expect((input[0] as any).id).toBe("fc_999")
  285. })
  286. test("preserves reasoning items keyed by the copilot namespace instead of dropping them", async () => {
  287. const { input, warnings } = await convertToOpenAIResponsesInput({
  288. prompt: [
  289. {
  290. role: "assistant",
  291. content: [
  292. {
  293. type: "reasoning",
  294. text: "thinking...",
  295. providerOptions: { copilot: { itemId: "rs_1", reasoningEncryptedContent: "enc_1" } },
  296. },
  297. ],
  298. },
  299. ],
  300. systemMessageMode: "system",
  301. store: false,
  302. })
  303. expect(warnings).toEqual([])
  304. expect(input).toEqual([
  305. {
  306. type: "reasoning",
  307. id: "rs_1",
  308. encrypted_content: "enc_1",
  309. summary: [],
  310. },
  311. ])
  312. })
  313. test("drops encrypted reasoning with no completed copilot itemId", async () => {
  314. const { input, warnings } = await convertToOpenAIResponsesInput({
  315. prompt: [
  316. {
  317. role: "assistant",
  318. content: [
  319. {
  320. type: "reasoning",
  321. text: "thinking...",
  322. providerOptions: { copilot: { reasoningEncryptedContent: "enc_1" } },
  323. },
  324. ],
  325. },
  326. ],
  327. systemMessageMode: "system",
  328. store: false,
  329. })
  330. expect(input).toEqual([])
  331. expect(warnings).toHaveLength(1)
  332. })
  333. test("drops reasoning with neither a copilot itemId nor encrypted content", async () => {
  334. const { input, warnings } = await convertToOpenAIResponsesInput({
  335. prompt: [
  336. {
  337. role: "assistant",
  338. content: [{ type: "reasoning", text: "thinking...", providerOptions: {} }],
  339. },
  340. ],
  341. systemMessageMode: "system",
  342. store: false,
  343. })
  344. expect(input).toEqual([])
  345. expect(warnings).toHaveLength(1)
  346. expect(warnings[0]).toMatchObject({
  347. message: expect.stringContaining("Non-OpenAI reasoning parts are not supported"),
  348. })
  349. })
  350. test("reads imageDetail from the copilot namespace on user file parts", async () => {
  351. const { input } = await convertToOpenAIResponsesInput({
  352. prompt: [
  353. {
  354. role: "user",
  355. content: [
  356. {
  357. type: "file",
  358. mediaType: "image/png",
  359. data: "aGVsbG8=",
  360. providerOptions: { copilot: { imageDetail: "high" } },
  361. },
  362. ],
  363. },
  364. ],
  365. systemMessageMode: "system",
  366. store: false,
  367. })
  368. expect((input[0] as any).content[0].detail).toBe("high")
  369. })
  370. })