openai-compatible-chat-language-model.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815
  1. import {
  2. APICallError,
  3. InvalidResponseDataError,
  4. type LanguageModelV3,
  5. type LanguageModelV3CallOptions,
  6. type LanguageModelV3Content,
  7. type LanguageModelV3StreamPart,
  8. type SharedV3ProviderMetadata,
  9. type SharedV3Warning,
  10. } from "@ai-sdk/provider"
  11. import {
  12. combineHeaders,
  13. createEventSourceResponseHandler,
  14. createJsonErrorResponseHandler,
  15. createJsonResponseHandler,
  16. type FetchFunction,
  17. generateId,
  18. isParsableJson,
  19. parseProviderOptions,
  20. type ParseResult,
  21. postJsonToApi,
  22. type ResponseHandler,
  23. } from "@ai-sdk/provider-utils"
  24. import { z } from "zod/v4"
  25. import { convertToOpenAICompatibleChatMessages } from "./convert-to-openai-compatible-chat-messages"
  26. import { getResponseMetadata } from "./get-response-metadata"
  27. import { mapOpenAICompatibleFinishReason } from "./map-openai-compatible-finish-reason"
  28. import { type OpenAICompatibleChatModelId, openaiCompatibleProviderOptions } from "./openai-compatible-chat-options"
  29. import { defaultOpenAICompatibleErrorStructure, type ProviderErrorStructure } from "../openai-compatible-error"
  30. import type { MetadataExtractor } from "./openai-compatible-metadata-extractor"
  31. import { prepareTools } from "./openai-compatible-prepare-tools"
  32. export type OpenAICompatibleChatConfig = {
  33. provider: string
  34. headers: () => Record<string, string | undefined>
  35. url: (options: { modelId: string; path: string }) => string
  36. fetch?: FetchFunction
  37. includeUsage?: boolean
  38. errorStructure?: ProviderErrorStructure<any>
  39. metadataExtractor?: MetadataExtractor
  40. /**
  41. * Whether the model supports structured outputs.
  42. */
  43. supportsStructuredOutputs?: boolean
  44. /**
  45. * The supported URLs for the model.
  46. */
  47. supportedUrls?: () => LanguageModelV3["supportedUrls"]
  48. }
  49. export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
  50. readonly specificationVersion = "v3"
  51. readonly supportsStructuredOutputs: boolean
  52. readonly modelId: OpenAICompatibleChatModelId
  53. private readonly config: OpenAICompatibleChatConfig
  54. private readonly failedResponseHandler: ResponseHandler<APICallError>
  55. private readonly chunkSchema // type inferred via constructor
  56. constructor(modelId: OpenAICompatibleChatModelId, config: OpenAICompatibleChatConfig) {
  57. this.modelId = modelId
  58. this.config = config
  59. // initialize error handling:
  60. const errorStructure = config.errorStructure ?? defaultOpenAICompatibleErrorStructure
  61. this.chunkSchema = createOpenAICompatibleChatChunkSchema(errorStructure.errorSchema)
  62. this.failedResponseHandler = createJsonErrorResponseHandler(errorStructure)
  63. this.supportsStructuredOutputs = config.supportsStructuredOutputs ?? false
  64. }
  65. get provider(): string {
  66. return this.config.provider
  67. }
  68. private get providerOptionsName(): string {
  69. return this.config.provider.split(".")[0].trim()
  70. }
  71. get supportedUrls() {
  72. return this.config.supportedUrls?.() ?? {}
  73. }
  74. private async getArgs({
  75. prompt,
  76. maxOutputTokens,
  77. temperature,
  78. topP,
  79. topK,
  80. frequencyPenalty,
  81. presencePenalty,
  82. providerOptions,
  83. stopSequences,
  84. responseFormat,
  85. seed,
  86. toolChoice,
  87. tools,
  88. }: LanguageModelV3CallOptions) {
  89. const warnings: SharedV3Warning[] = []
  90. // Parse provider options
  91. const compatibleOptions = Object.assign(
  92. (await parseProviderOptions({
  93. provider: "copilot",
  94. providerOptions,
  95. schema: openaiCompatibleProviderOptions,
  96. })) ?? {},
  97. (await parseProviderOptions({
  98. provider: this.providerOptionsName,
  99. providerOptions,
  100. schema: openaiCompatibleProviderOptions,
  101. })) ?? {},
  102. )
  103. if (topK != null) {
  104. warnings.push({ type: "unsupported", feature: "topK" })
  105. }
  106. if (responseFormat?.type === "json" && responseFormat.schema != null && !this.supportsStructuredOutputs) {
  107. warnings.push({
  108. type: "unsupported",
  109. feature: "responseFormat",
  110. details: "JSON response format schema is only supported with structuredOutputs",
  111. })
  112. }
  113. const {
  114. tools: openaiTools,
  115. toolChoice: openaiToolChoice,
  116. toolWarnings,
  117. } = prepareTools({
  118. tools,
  119. toolChoice,
  120. })
  121. return {
  122. args: {
  123. // model id:
  124. model: this.modelId,
  125. // model specific settings:
  126. user: compatibleOptions.user,
  127. // standardized settings:
  128. max_tokens: maxOutputTokens,
  129. temperature,
  130. top_p: topP,
  131. frequency_penalty: frequencyPenalty,
  132. presence_penalty: presencePenalty,
  133. response_format:
  134. responseFormat?.type === "json"
  135. ? this.supportsStructuredOutputs === true && responseFormat.schema != null
  136. ? {
  137. type: "json_schema",
  138. json_schema: {
  139. schema: responseFormat.schema,
  140. name: responseFormat.name ?? "response",
  141. description: responseFormat.description,
  142. },
  143. }
  144. : { type: "json_object" }
  145. : undefined,
  146. stop: stopSequences,
  147. seed,
  148. ...Object.fromEntries(
  149. Object.entries(providerOptions?.[this.providerOptionsName] ?? {}).filter(
  150. ([key]) => !Object.keys(openaiCompatibleProviderOptions.shape).includes(key),
  151. ),
  152. ),
  153. reasoning_effort: compatibleOptions.reasoningEffort,
  154. verbosity: compatibleOptions.textVerbosity,
  155. // messages:
  156. messages: convertToOpenAICompatibleChatMessages(prompt),
  157. // tools:
  158. tools: openaiTools,
  159. tool_choice: openaiToolChoice,
  160. // thinking_budget
  161. thinking_budget: compatibleOptions.thinking_budget,
  162. },
  163. warnings: [...warnings, ...toolWarnings],
  164. }
  165. }
  166. async doGenerate(options: LanguageModelV3CallOptions) {
  167. const { args, warnings } = await this.getArgs({ ...options })
  168. const body = JSON.stringify(args)
  169. const {
  170. responseHeaders,
  171. value: responseBody,
  172. rawValue: rawResponse,
  173. } = await postJsonToApi({
  174. url: this.config.url({
  175. path: "/chat/completions",
  176. modelId: this.modelId,
  177. }),
  178. headers: combineHeaders(this.config.headers(), options.headers),
  179. body: args,
  180. failedResponseHandler: this.failedResponseHandler,
  181. successfulResponseHandler: createJsonResponseHandler(OpenAICompatibleChatResponseSchema),
  182. abortSignal: options.abortSignal,
  183. fetch: this.config.fetch,
  184. })
  185. const choice = responseBody.choices[0]
  186. const content: Array<LanguageModelV3Content> = []
  187. // text content:
  188. const text = choice.message.content
  189. if (text != null && text.length > 0) {
  190. content.push({
  191. type: "text",
  192. text,
  193. providerMetadata: choice.message.reasoning_opaque
  194. ? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } }
  195. : undefined,
  196. })
  197. }
  198. // reasoning content (Copilot uses reasoning_text):
  199. const reasoning = choice.message.reasoning_text
  200. if (reasoning != null && reasoning.length > 0) {
  201. content.push({
  202. type: "reasoning",
  203. text: reasoning,
  204. // Include reasoning_opaque for Copilot multi-turn reasoning
  205. providerMetadata: choice.message.reasoning_opaque
  206. ? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } }
  207. : undefined,
  208. })
  209. }
  210. // tool calls:
  211. if (choice.message.tool_calls != null) {
  212. for (const toolCall of choice.message.tool_calls) {
  213. content.push({
  214. type: "tool-call",
  215. toolCallId: toolCall.id ?? generateId(),
  216. toolName: toolCall.function.name,
  217. input: toolCall.function.arguments!,
  218. providerMetadata: choice.message.reasoning_opaque
  219. ? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } }
  220. : undefined,
  221. })
  222. }
  223. }
  224. // provider metadata:
  225. const providerMetadata: SharedV3ProviderMetadata = {
  226. [this.providerOptionsName]: {},
  227. ...(await this.config.metadataExtractor?.extractMetadata?.({
  228. parsedBody: rawResponse,
  229. })),
  230. }
  231. const completionTokenDetails = responseBody.usage?.completion_tokens_details
  232. if (completionTokenDetails?.accepted_prediction_tokens != null) {
  233. providerMetadata[this.providerOptionsName].acceptedPredictionTokens =
  234. completionTokenDetails?.accepted_prediction_tokens
  235. }
  236. if (completionTokenDetails?.rejected_prediction_tokens != null) {
  237. providerMetadata[this.providerOptionsName].rejectedPredictionTokens =
  238. completionTokenDetails?.rejected_prediction_tokens
  239. }
  240. return {
  241. content,
  242. finishReason: {
  243. unified: mapOpenAICompatibleFinishReason(choice.finish_reason),
  244. raw: choice.finish_reason ?? undefined,
  245. },
  246. usage: {
  247. inputTokens: {
  248. total: responseBody.usage?.prompt_tokens ?? undefined,
  249. noCache: undefined,
  250. cacheRead: responseBody.usage?.prompt_tokens_details?.cached_tokens ?? undefined,
  251. cacheWrite: undefined,
  252. },
  253. outputTokens: {
  254. total: responseBody.usage?.completion_tokens ?? undefined,
  255. text: undefined,
  256. reasoning: responseBody.usage?.completion_tokens_details?.reasoning_tokens ?? undefined,
  257. },
  258. raw: responseBody.usage ?? undefined,
  259. },
  260. providerMetadata,
  261. request: { body },
  262. response: {
  263. ...getResponseMetadata(responseBody),
  264. headers: responseHeaders,
  265. body: rawResponse,
  266. },
  267. warnings,
  268. }
  269. }
  270. async doStream(options: LanguageModelV3CallOptions) {
  271. const { args, warnings } = await this.getArgs({ ...options })
  272. const body = {
  273. ...args,
  274. stream: true,
  275. // only include stream_options when in strict compatibility mode:
  276. stream_options: this.config.includeUsage ? { include_usage: true } : undefined,
  277. }
  278. const metadataExtractor = this.config.metadataExtractor?.createStreamExtractor()
  279. const { responseHeaders, value: response } = await postJsonToApi({
  280. url: this.config.url({
  281. path: "/chat/completions",
  282. modelId: this.modelId,
  283. }),
  284. headers: combineHeaders(this.config.headers(), options.headers),
  285. body,
  286. failedResponseHandler: this.failedResponseHandler,
  287. successfulResponseHandler: createEventSourceResponseHandler(this.chunkSchema),
  288. abortSignal: options.abortSignal,
  289. fetch: this.config.fetch,
  290. })
  291. const toolCalls: Array<{
  292. id: string
  293. type: "function"
  294. function: {
  295. name: string
  296. arguments: string
  297. }
  298. hasFinished: boolean
  299. }> = []
  300. let finishReason: {
  301. unified: ReturnType<typeof mapOpenAICompatibleFinishReason>
  302. raw: string | undefined
  303. } = {
  304. unified: "other",
  305. raw: undefined,
  306. }
  307. const usage: {
  308. completionTokens: number | undefined
  309. completionTokensDetails: {
  310. reasoningTokens: number | undefined
  311. acceptedPredictionTokens: number | undefined
  312. rejectedPredictionTokens: number | undefined
  313. }
  314. promptTokens: number | undefined
  315. promptTokensDetails: {
  316. cachedTokens: number | undefined
  317. }
  318. totalTokens: number | undefined
  319. } = {
  320. completionTokens: undefined,
  321. completionTokensDetails: {
  322. reasoningTokens: undefined,
  323. acceptedPredictionTokens: undefined,
  324. rejectedPredictionTokens: undefined,
  325. },
  326. promptTokens: undefined,
  327. promptTokensDetails: {
  328. cachedTokens: undefined,
  329. },
  330. totalTokens: undefined,
  331. }
  332. let isFirstChunk = true
  333. const providerOptionsName = this.providerOptionsName
  334. let isActiveReasoning = false
  335. let isActiveText = false
  336. let reasoningOpaque: string | undefined
  337. return {
  338. stream: response.pipeThrough(
  339. new TransformStream<ParseResult<z.infer<typeof this.chunkSchema>>, LanguageModelV3StreamPart>({
  340. start(controller) {
  341. controller.enqueue({ type: "stream-start", warnings })
  342. },
  343. // TODO we lost type safety on Chunk, most likely due to the error schema. MUST FIX
  344. transform(chunk, controller) {
  345. // Emit raw chunk if requested (before anything else)
  346. if (options.includeRawChunks) {
  347. controller.enqueue({ type: "raw", rawValue: chunk.rawValue })
  348. }
  349. // handle failed chunk parsing / validation:
  350. if (!chunk.success) {
  351. finishReason = {
  352. unified: "error",
  353. raw: undefined,
  354. }
  355. controller.enqueue({ type: "error", error: chunk.error })
  356. return
  357. }
  358. const value = chunk.value
  359. metadataExtractor?.processChunk(chunk.rawValue)
  360. // handle error chunks:
  361. if ("error" in value) {
  362. finishReason = {
  363. unified: "error",
  364. raw: undefined,
  365. }
  366. controller.enqueue({ type: "error", error: value.error.message })
  367. return
  368. }
  369. if (isFirstChunk) {
  370. isFirstChunk = false
  371. controller.enqueue({
  372. type: "response-metadata",
  373. ...getResponseMetadata(value),
  374. })
  375. }
  376. if (value.usage != null) {
  377. const {
  378. prompt_tokens,
  379. completion_tokens,
  380. total_tokens,
  381. prompt_tokens_details,
  382. completion_tokens_details,
  383. } = value.usage
  384. usage.promptTokens = prompt_tokens ?? undefined
  385. usage.completionTokens = completion_tokens ?? undefined
  386. usage.totalTokens = total_tokens ?? undefined
  387. if (completion_tokens_details?.reasoning_tokens != null) {
  388. usage.completionTokensDetails.reasoningTokens = completion_tokens_details?.reasoning_tokens
  389. }
  390. if (completion_tokens_details?.accepted_prediction_tokens != null) {
  391. usage.completionTokensDetails.acceptedPredictionTokens =
  392. completion_tokens_details?.accepted_prediction_tokens
  393. }
  394. if (completion_tokens_details?.rejected_prediction_tokens != null) {
  395. usage.completionTokensDetails.rejectedPredictionTokens =
  396. completion_tokens_details?.rejected_prediction_tokens
  397. }
  398. if (prompt_tokens_details?.cached_tokens != null) {
  399. usage.promptTokensDetails.cachedTokens = prompt_tokens_details?.cached_tokens
  400. }
  401. }
  402. const choice = value.choices[0]
  403. if (choice?.finish_reason != null) {
  404. finishReason = {
  405. unified: mapOpenAICompatibleFinishReason(choice.finish_reason),
  406. raw: choice.finish_reason ?? undefined,
  407. }
  408. }
  409. if (choice?.delta == null) {
  410. return
  411. }
  412. const delta = choice.delta
  413. // Capture reasoning_opaque for Copilot multi-turn reasoning
  414. if (delta.reasoning_opaque) {
  415. if (reasoningOpaque != null) {
  416. throw new InvalidResponseDataError({
  417. data: delta,
  418. message:
  419. "Multiple reasoning_opaque values received in a single response. Only one thinking part per response is supported.",
  420. })
  421. }
  422. reasoningOpaque = delta.reasoning_opaque
  423. }
  424. // enqueue reasoning before text deltas (Copilot uses reasoning_text):
  425. const reasoningContent = delta.reasoning_text
  426. if (reasoningContent) {
  427. if (!isActiveReasoning) {
  428. controller.enqueue({
  429. type: "reasoning-start",
  430. id: "reasoning-0",
  431. })
  432. isActiveReasoning = true
  433. }
  434. controller.enqueue({
  435. type: "reasoning-delta",
  436. id: "reasoning-0",
  437. delta: reasoningContent,
  438. })
  439. }
  440. if (delta.content) {
  441. // If reasoning was active and we're starting text, end reasoning first
  442. // This handles the case where reasoning_opaque and content come in the same chunk
  443. if (isActiveReasoning && !isActiveText) {
  444. controller.enqueue({
  445. type: "reasoning-end",
  446. id: "reasoning-0",
  447. providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
  448. })
  449. isActiveReasoning = false
  450. }
  451. if (!isActiveText) {
  452. controller.enqueue({
  453. type: "text-start",
  454. id: "txt-0",
  455. providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
  456. })
  457. isActiveText = true
  458. }
  459. controller.enqueue({
  460. type: "text-delta",
  461. id: "txt-0",
  462. delta: delta.content,
  463. })
  464. }
  465. if (delta.tool_calls != null) {
  466. // If reasoning was active and we're starting tool calls, end reasoning first
  467. // This handles the case where reasoning goes directly to tool calls with no content
  468. if (isActiveReasoning) {
  469. controller.enqueue({
  470. type: "reasoning-end",
  471. id: "reasoning-0",
  472. providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
  473. })
  474. isActiveReasoning = false
  475. }
  476. for (const toolCallDelta of delta.tool_calls) {
  477. const index = toolCallDelta.index
  478. if (toolCalls[index] == null) {
  479. if (toolCallDelta.id == null) {
  480. throw new InvalidResponseDataError({
  481. data: toolCallDelta,
  482. message: `Expected 'id' to be a string.`,
  483. })
  484. }
  485. if (toolCallDelta.function?.name == null) {
  486. throw new InvalidResponseDataError({
  487. data: toolCallDelta,
  488. message: `Expected 'function.name' to be a string.`,
  489. })
  490. }
  491. controller.enqueue({
  492. type: "tool-input-start",
  493. id: toolCallDelta.id,
  494. toolName: toolCallDelta.function.name,
  495. })
  496. toolCalls[index] = {
  497. id: toolCallDelta.id,
  498. type: "function",
  499. function: {
  500. name: toolCallDelta.function.name,
  501. arguments: toolCallDelta.function.arguments ?? "",
  502. },
  503. hasFinished: false,
  504. }
  505. const toolCall = toolCalls[index]
  506. if (toolCall.function?.name != null && toolCall.function?.arguments != null) {
  507. // send delta if the argument text has already started:
  508. if (toolCall.function.arguments.length > 0) {
  509. controller.enqueue({
  510. type: "tool-input-delta",
  511. id: toolCall.id,
  512. delta: toolCall.function.arguments,
  513. })
  514. }
  515. // check if tool call is complete
  516. // (some providers send the full tool call in one chunk):
  517. if (isParsableJson(toolCall.function.arguments)) {
  518. controller.enqueue({
  519. type: "tool-input-end",
  520. id: toolCall.id,
  521. })
  522. controller.enqueue({
  523. type: "tool-call",
  524. toolCallId: toolCall.id ?? generateId(),
  525. toolName: toolCall.function.name,
  526. input: toolCall.function.arguments,
  527. providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
  528. })
  529. toolCall.hasFinished = true
  530. }
  531. }
  532. continue
  533. }
  534. // existing tool call, merge if not finished
  535. const toolCall = toolCalls[index]
  536. if (toolCall.hasFinished) {
  537. continue
  538. }
  539. if (toolCallDelta.function?.arguments != null) {
  540. toolCall.function!.arguments += toolCallDelta.function?.arguments ?? ""
  541. }
  542. // send delta
  543. controller.enqueue({
  544. type: "tool-input-delta",
  545. id: toolCall.id,
  546. delta: toolCallDelta.function.arguments ?? "",
  547. })
  548. // check if tool call is complete
  549. if (
  550. toolCall.function?.name != null &&
  551. toolCall.function?.arguments != null &&
  552. isParsableJson(toolCall.function.arguments)
  553. ) {
  554. controller.enqueue({
  555. type: "tool-input-end",
  556. id: toolCall.id,
  557. })
  558. controller.enqueue({
  559. type: "tool-call",
  560. toolCallId: toolCall.id ?? generateId(),
  561. toolName: toolCall.function.name,
  562. input: toolCall.function.arguments,
  563. providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
  564. })
  565. toolCall.hasFinished = true
  566. }
  567. }
  568. }
  569. },
  570. flush(controller) {
  571. if (isActiveReasoning) {
  572. controller.enqueue({
  573. type: "reasoning-end",
  574. id: "reasoning-0",
  575. // Include reasoning_opaque for Copilot multi-turn reasoning
  576. providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
  577. })
  578. }
  579. if (isActiveText) {
  580. controller.enqueue({ type: "text-end", id: "txt-0" })
  581. }
  582. // go through all tool calls and send the ones that are not finished
  583. for (const toolCall of toolCalls.filter((toolCall) => !toolCall.hasFinished)) {
  584. controller.enqueue({
  585. type: "tool-input-end",
  586. id: toolCall.id,
  587. })
  588. controller.enqueue({
  589. type: "tool-call",
  590. toolCallId: toolCall.id ?? generateId(),
  591. toolName: toolCall.function.name,
  592. input: toolCall.function.arguments,
  593. })
  594. }
  595. const providerMetadata: SharedV3ProviderMetadata = {
  596. [providerOptionsName]: {},
  597. // Include reasoning_opaque for Copilot multi-turn reasoning
  598. ...(reasoningOpaque ? { copilot: { reasoningOpaque } } : {}),
  599. ...metadataExtractor?.buildMetadata(),
  600. }
  601. if (usage.completionTokensDetails.acceptedPredictionTokens != null) {
  602. providerMetadata[providerOptionsName].acceptedPredictionTokens =
  603. usage.completionTokensDetails.acceptedPredictionTokens
  604. }
  605. if (usage.completionTokensDetails.rejectedPredictionTokens != null) {
  606. providerMetadata[providerOptionsName].rejectedPredictionTokens =
  607. usage.completionTokensDetails.rejectedPredictionTokens
  608. }
  609. controller.enqueue({
  610. type: "finish",
  611. finishReason,
  612. usage: {
  613. inputTokens: {
  614. total: usage.promptTokens,
  615. noCache:
  616. usage.promptTokens != undefined && usage.promptTokensDetails.cachedTokens != undefined
  617. ? usage.promptTokens - usage.promptTokensDetails.cachedTokens
  618. : undefined,
  619. cacheRead: usage.promptTokensDetails.cachedTokens,
  620. cacheWrite: undefined,
  621. },
  622. outputTokens: {
  623. total: usage.completionTokens,
  624. text: undefined,
  625. reasoning: usage.completionTokensDetails.reasoningTokens,
  626. },
  627. raw: {
  628. prompt_tokens: usage.promptTokens ?? null,
  629. completion_tokens: usage.completionTokens ?? null,
  630. total_tokens: usage.totalTokens ?? null,
  631. },
  632. },
  633. providerMetadata,
  634. })
  635. },
  636. }),
  637. ),
  638. request: { body },
  639. response: { headers: responseHeaders },
  640. }
  641. }
  642. }
  643. const openaiCompatibleTokenUsageSchema = z
  644. .object({
  645. prompt_tokens: z.number().nullish(),
  646. completion_tokens: z.number().nullish(),
  647. total_tokens: z.number().nullish(),
  648. prompt_tokens_details: z
  649. .object({
  650. cached_tokens: z.number().nullish(),
  651. })
  652. .nullish(),
  653. completion_tokens_details: z
  654. .object({
  655. reasoning_tokens: z.number().nullish(),
  656. accepted_prediction_tokens: z.number().nullish(),
  657. rejected_prediction_tokens: z.number().nullish(),
  658. })
  659. .nullish(),
  660. })
  661. .nullish()
  662. // limited version of the schema, focussed on what is needed for the implementation
  663. // this approach limits breakages when the API changes and increases efficiency
  664. const OpenAICompatibleChatResponseSchema = z.object({
  665. id: z.string().nullish(),
  666. created: z.number().nullish(),
  667. model: z.string().nullish(),
  668. choices: z.array(
  669. z.object({
  670. message: z.object({
  671. role: z.literal("assistant").nullish(),
  672. content: z.string().nullish(),
  673. // Copilot-specific reasoning fields
  674. reasoning_text: z.string().nullish(),
  675. reasoning_opaque: z.string().nullish(),
  676. tool_calls: z
  677. .array(
  678. z.object({
  679. id: z.string().nullish(),
  680. function: z.object({
  681. name: z.string(),
  682. arguments: z.string(),
  683. }),
  684. }),
  685. )
  686. .nullish(),
  687. }),
  688. finish_reason: z.string().nullish(),
  689. }),
  690. ),
  691. usage: openaiCompatibleTokenUsageSchema,
  692. })
  693. // limited version of the schema, focussed on what is needed for the implementation
  694. // this approach limits breakages when the API changes and increases efficiency
  695. const createOpenAICompatibleChatChunkSchema = <ERROR_SCHEMA extends z.core.$ZodType>(errorSchema: ERROR_SCHEMA) =>
  696. z.union([
  697. z.object({
  698. id: z.string().nullish(),
  699. created: z.number().nullish(),
  700. model: z.string().nullish(),
  701. choices: z.array(
  702. z.object({
  703. delta: z
  704. .object({
  705. role: z.enum(["assistant"]).nullish(),
  706. content: z.string().nullish(),
  707. // Copilot-specific reasoning fields
  708. reasoning_text: z.string().nullish(),
  709. reasoning_opaque: z.string().nullish(),
  710. tool_calls: z
  711. .array(
  712. z.object({
  713. index: z.number(),
  714. id: z.string().nullish(),
  715. function: z.object({
  716. name: z.string().nullish(),
  717. arguments: z.string().nullish(),
  718. }),
  719. }),
  720. )
  721. .nullish(),
  722. })
  723. .nullish(),
  724. finish_reason: z.string().nullish(),
  725. }),
  726. ),
  727. usage: openaiCompatibleTokenUsageSchema,
  728. }),
  729. errorSchema,
  730. ])