cf-ai-gateway-e2e.test.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
  1. // End-to-end regression test for opencode#24432.
  2. //
  3. // Routes through the actual ai-gateway-provider + @ai-sdk/openai-compatible
  4. // chain that provider.ts:811 builds at runtime, with only the network boundary
  5. // stubbed. Asserts that `reasoning_effort` (and other provider options the
  6. // transform emits) actually land in the body Cloudflare AI Gateway forwards
  7. // upstream, which is the only place the bug was observable.
  8. import { afterEach, beforeEach, describe, expect, test } from "bun:test"
  9. import type { JSONValue } from "ai"
  10. import { generateText } from "ai"
  11. import { createAiGateway } from "ai-gateway-provider"
  12. import { createUnified } from "ai-gateway-provider/providers/unified"
  13. import { ProviderTransform } from "@/provider/transform"
  14. import type * as Provider from "@/provider/provider"
  15. import { ProviderV2 } from "@kirincode-ai/core/provider"
  16. import { ModelV2 } from "@kirincode-ai/core/model"
  17. type Captured = { url: string; outerBody: unknown }
  18. type ProviderOptions = Record<string, Record<string, JSONValue>>
  19. const realFetch = globalThis.fetch
  20. let captured: Captured | null = null
  21. function isRecord(value: unknown): value is Record<string, unknown> {
  22. return typeof value === "object" && value !== null && !Array.isArray(value)
  23. }
  24. beforeEach(() => {
  25. captured = null
  26. const handle = async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]): Promise<Response> => {
  27. const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url
  28. if (url.startsWith("https://gateway.ai.cloudflare.com/")) {
  29. const bodyText = typeof init?.body === "string" ? init.body : ""
  30. captured = { url, outerBody: bodyText ? JSON.parse(bodyText) : null }
  31. return new Response(
  32. JSON.stringify({
  33. id: "chatcmpl-test",
  34. object: "chat.completion",
  35. created: 0,
  36. model: "openai/gpt-5.4",
  37. choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }],
  38. usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
  39. }),
  40. { status: 200, headers: { "Content-Type": "application/json" } },
  41. )
  42. }
  43. return realFetch(input, init)
  44. }
  45. // `typeof fetch` includes Bun's `preconnect` method; preserve it from realFetch.
  46. const stubFetch: typeof fetch = Object.assign(handle, { preconnect: realFetch.preconnect.bind(realFetch) })
  47. globalThis.fetch = stubFetch
  48. })
  49. afterEach(() => {
  50. globalThis.fetch = realFetch
  51. })
  52. const cfModel = (apiId: string, releaseDate = "2026-03-05"): Provider.Model => ({
  53. id: ModelV2.ID.make(`cloudflare-ai-gateway/${apiId}`),
  54. providerID: ProviderV2.ID.make("cloudflare-ai-gateway"),
  55. name: apiId,
  56. api: { id: apiId, url: "https://gateway.ai.cloudflare.com/v1/compat", npm: "ai-gateway-provider" },
  57. capabilities: {
  58. reasoning: true,
  59. temperature: false,
  60. attachment: true,
  61. toolcall: true,
  62. input: { text: true, audio: false, image: true, video: false, pdf: true },
  63. output: { text: true, audio: false, image: false, video: false, pdf: false },
  64. interleaved: false,
  65. },
  66. cost: { input: 1, output: 1, cache: { read: 0, write: 0 } },
  67. limit: { context: 1_000_000, output: 128_000 },
  68. status: "active",
  69. options: {},
  70. headers: {},
  71. release_date: releaseDate,
  72. })
  73. // ai-gateway-provider sends an array of step descriptors; each entry's `query`
  74. // is the body forwarded to the upstream provider.
  75. function extractUpstreamQuery(body: unknown): Record<string, unknown> | undefined {
  76. if (!Array.isArray(body) || body.length === 0) return undefined
  77. const first = body[0]
  78. if (!isRecord(first)) return undefined
  79. const query = first.query
  80. return isRecord(query) ? query : undefined
  81. }
  82. async function callThroughGateway(apiId: string, providerOptions: ProviderOptions) {
  83. const aigateway = createAiGateway({ accountId: "test", gateway: "test", apiKey: "test" })
  84. const unified = createUnified()
  85. await generateText({ model: aigateway(unified(apiId)), prompt: "hi", providerOptions })
  86. return extractUpstreamQuery(captured?.outerBody)
  87. }
  88. describe("cf-ai-gateway end-to-end (regression: #24432)", () => {
  89. test("ProviderTransform.providerOptions output puts reasoning_effort on the wire", async () => {
  90. // The full chain the runtime exercises:
  91. // transform.providerOptions() -> openaiCompatible key
  92. // -> @ai-sdk/openai-compatible reads it as compatibleOptions
  93. // -> emits body.reasoning_effort
  94. // -> ai-gateway-provider wraps the body and forwards to gateway.ai.cloudflare.com
  95. const opts = ProviderTransform.providerOptions(cfModel("openai/gpt-5.4"), { reasoningEffort: "xhigh" })
  96. expect(opts).toEqual({ openaiCompatible: { reasoningEffort: "xhigh" } })
  97. const upstream = await callThroughGateway("openai/gpt-5.4", opts)
  98. expect(upstream?.reasoning_effort).toBe("xhigh")
  99. })
  100. test("variants() output for openai/gpt-5.4 lands xhigh on the wire", async () => {
  101. // The other half of the bug: workflow `variant: xhigh` flows through variants()
  102. // and must reach the wire. variants() returns the providerOptions payload
  103. // unwrapped; providerOptions() wraps it under the SDK key.
  104. const variants = ProviderTransform.variants(cfModel("openai/gpt-5.4"))
  105. expect(variants.xhigh).toEqual({ reasoningEffort: "xhigh" })
  106. const opts = ProviderTransform.providerOptions(cfModel("openai/gpt-5.4"), variants.xhigh)
  107. const upstream = await callThroughGateway("openai/gpt-5.4", opts)
  108. expect(upstream?.reasoning_effort).toBe("xhigh")
  109. })
  110. test("legacy buggy key 'cloudflare-ai-gateway' does NOT reach the wire (proves the bug)", async () => {
  111. // Sanity: confirms the bug class. If a future change accidentally restores
  112. // providerID-keyed providerOptions, this test fails before users notice.
  113. const upstream = await callThroughGateway("openai/gpt-5.4", {
  114. "cloudflare-ai-gateway": { reasoningEffort: "high" },
  115. })
  116. expect(upstream?.reasoning_effort).toBeUndefined()
  117. })
  118. })