Просмотр исходного кода

fix(core): normalize copilot reasoning usage

Aiden Cline 1 неделя назад
Родитель
Сommit
24d8e41aab

+ 16 - 7
packages/core/src/github-copilot/chat/openai-compatible-chat-language-model.ts

@@ -285,9 +285,8 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
           cacheWrite: undefined,
         },
         outputTokens: {
-          total: responseBody.usage?.completion_tokens ?? undefined,
+          ...outputUsage(responseBody.usage),
           text: undefined,
-          reasoning: responseBody.usage?.completion_tokens_details?.reasoning_tokens ?? undefined,
         },
         raw: responseBody.usage ?? undefined,
       },
@@ -425,18 +424,16 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 {
             if (value.usage != null) {
               const {
                 prompt_tokens,
-                completion_tokens,
                 total_tokens,
                 prompt_tokens_details,
                 completion_tokens_details,
               } = value.usage
 
               usage.promptTokens = prompt_tokens ?? undefined
-              usage.completionTokens = completion_tokens ?? undefined
+              const output = outputUsage(value.usage)
+              usage.completionTokens = output.total
+              usage.completionTokensDetails.reasoningTokens = output.reasoning
               usage.totalTokens = total_tokens ?? undefined
-              if (completion_tokens_details?.reasoning_tokens != null) {
-                usage.completionTokensDetails.reasoningTokens = completion_tokens_details?.reasoning_tokens
-              }
               if (completion_tokens_details?.accepted_prediction_tokens != null) {
                 usage.completionTokensDetails.acceptedPredictionTokens =
                   completion_tokens_details?.accepted_prediction_tokens
@@ -727,6 +724,7 @@ const openaiCompatibleTokenUsageSchema = z
   .object({
     prompt_tokens: z.number().nullish(),
     completion_tokens: z.number().nullish(),
+    reasoning_tokens: z.number().nullish(),
     total_tokens: z.number().nullish(),
     prompt_tokens_details: z
       .object({
@@ -743,6 +741,17 @@ const openaiCompatibleTokenUsageSchema = z
   })
   .nullish()
 
+function outputUsage(usage: z.infer<typeof openaiCompatibleTokenUsageSchema>) {
+  const nested = usage?.completion_tokens_details?.reasoning_tokens
+  return {
+    total:
+      usage?.completion_tokens == null
+        ? undefined
+        : usage.completion_tokens + (nested == null ? (usage.reasoning_tokens ?? 0) : 0),
+    reasoning: nested ?? usage?.reasoning_tokens ?? undefined,
+  }
+}
+
 // limited version of the schema, focussed on what is needed for the implementation
 // this approach limits breakages when the API changes and increases efficiency
 const OpenAICompatibleChatResponseSchema = z.object({

+ 47 - 3
packages/core/test/github-copilot/copilot-chat-model.test.ts

@@ -24,6 +24,11 @@ const FIXTURES = {
     `data: [DONE]`,
   ],
 
+  nestedReasoningUsage: [
+    `data: {"id":"chatcmpl-usage","object":"chat.completion.chunk","created":1677652288,"model":"gpt-test","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":"stop"}],"usage":{"completion_tokens":187,"completion_tokens_details":{"reasoning_tokens":134},"prompt_tokens":100,"total_tokens":287}}`,
+    `data: [DONE]`,
+  ],
+
   reasoningWithToolCalls: [
     `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Understanding Dayzee's Purpose**\\n\\nI'm starting to get a better handle on \`dayzee\`.\\n\\n"}}],"created":1764940861,"id":"OdwyabKMI9yel7oPlbzgwQM","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
     `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Assessing Dayzee's Functionality**\\n\\nI've reviewed the files.\\n\\n"}}],"created":1764940862,"id":"OdwyabKMI9yel7oPlbzgwQM","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`,
@@ -91,6 +96,18 @@ function createMockFetch(chunks: string[]) {
   })
 }
 
+function createMockGenerateFetch() {
+  return mock(async () =>
+    Response.json({
+      id: "chatcmpl-generate",
+      created: 1677652288,
+      model: "gemini-test",
+      choices: [{ message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
+      usage: { prompt_tokens: 100, completion_tokens: 53, reasoning_tokens: 134, total_tokens: 287 },
+    }),
+  )
+}
+
 function createModel(fetchFn: ReturnType<typeof mock>) {
   return new OpenAICompatibleChatLanguageModel("test-model", {
     provider: "copilot.chat",
@@ -100,6 +117,20 @@ function createModel(fetchFn: ReturnType<typeof mock>) {
   })
 }
 
+describe("doGenerate", () => {
+  test("should include top-level reasoning tokens in output usage", async () => {
+    const result = await createModel(createMockGenerateFetch()).doGenerate({
+      prompt: TEST_PROMPT,
+      includeRawChunks: false,
+    })
+
+    expect(result.usage).toMatchObject({
+      inputTokens: { total: 100 },
+      outputTokens: { total: 187, reasoning: 134 },
+    })
+  })
+})
+
 describe("doStream", () => {
   test("should stream text deltas", async () => {
     const mockFetch = createMockFetch(FIXTURES.basicText)
@@ -204,7 +235,20 @@ describe("doStream", () => {
       finishReason: { unified: "tool-calls" },
       usage: {
         inputTokens: { total: 19581 },
-        outputTokens: { total: 53 },
+        outputTokens: { total: 187, reasoning: 134 },
+      },
+    })
+  })
+
+  test("should not add nested reasoning tokens to inclusive completion tokens", async () => {
+    const model = createModel(createMockFetch(FIXTURES.nestedReasoningUsage))
+    const { stream } = await model.doStream({ prompt: TEST_PROMPT, includeRawChunks: false })
+    const finish = (await convertReadableStreamToArray(stream)).find((part) => part.type === "finish")
+
+    expect(finish).toMatchObject({
+      usage: {
+        inputTokens: { total: 100 },
+        outputTokens: { total: 187, reasoning: 134 },
       },
     })
   })
@@ -259,7 +303,7 @@ describe("doStream", () => {
       finishReason: { unified: "stop" },
       usage: {
         inputTokens: { total: 5778 },
-        outputTokens: { total: 59 },
+        outputTokens: { total: 154, reasoning: 95 },
       },
       providerMetadata: {
         copilot: {
@@ -391,7 +435,7 @@ describe("doStream", () => {
       finishReason: { unified: "tool-calls" },
       usage: {
         inputTokens: { total: 3767 },
-        outputTokens: { total: 19 },
+        outputTokens: { total: 30, reasoning: 11 },
       },
     })
   })