2
0
Эх сурвалжийг харах

fix(proxy): add OpenAI chat completion format support in usage extraction (#705) (#716)

The `extractUsageMetrics` function was missing support for OpenAI chat
completion format fields (`prompt_tokens`/`completion_tokens`), causing
token statistics to not be recorded for OpenAI-compatible providers.

Changes:
- Add `prompt_tokens` -> `input_tokens` mapping
- Add `completion_tokens` -> `output_tokens` mapping
- Preserve priority: Claude > Gemini > OpenAI format
- Add 5 unit tests for OpenAI format handling

Closes #705

Co-authored-by: Claude Opus 4.5 <[email protected]>
Ding 2 сар өмнө
parent
commit
9c85b21c44

+ 14 - 0
src/app/v1/_lib/proxy/response-handler.ts

@@ -1193,6 +1193,13 @@ function extractUsageMetrics(value: unknown): UsageMetrics | null {
     result.output_tokens = usage.candidatesTokenCount;
     hasAny = true;
   }
+
+  // OpenAI chat completion format: prompt_tokens → input_tokens
+  // Priority: Claude (input_tokens) > Gemini (promptTokenCount) > OpenAI (prompt_tokens)
+  if (result.input_tokens === undefined && typeof usage.prompt_tokens === "number") {
+    result.input_tokens = usage.prompt_tokens;
+    hasAny = true;
+  }
   // Gemini 缓存支持
   if (typeof usage.cachedContentTokenCount === "number") {
     result.cache_read_input_tokens = usage.cachedContentTokenCount;
@@ -1278,6 +1285,13 @@ function extractUsageMetrics(value: unknown): UsageMetrics | null {
     hasAny = true;
   }
 
+  // OpenAI chat completion format: completion_tokens → output_tokens
+  // Priority: Claude (output_tokens) > Gemini (candidatesTokenCount/thoughtsTokenCount) > OpenAI (completion_tokens)
+  if (result.output_tokens === undefined && typeof usage.completion_tokens === "number") {
+    result.output_tokens = usage.completion_tokens;
+    hasAny = true;
+  }
+
   if (typeof usage.cache_creation_input_tokens === "number") {
     result.cache_creation_input_tokens = usage.cache_creation_input_tokens;
     hasAny = true;

+ 82 - 0
tests/unit/proxy/extract-usage-metrics.test.ts

@@ -670,4 +670,86 @@ describe("extractUsageMetrics", () => {
       expect(result.usageMetrics).toBeNull();
     });
   });
+
+  describe("OpenAI chat completion format (prompt_tokens/completion_tokens)", () => {
+    it("should extract prompt_tokens as input_tokens", () => {
+      const response = JSON.stringify({
+        usage: {
+          prompt_tokens: 100,
+          completion_tokens: 50,
+          total_tokens: 150,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "openai");
+
+      expect(result.usageMetrics).not.toBeNull();
+      expect(result.usageMetrics?.input_tokens).toBe(100);
+      expect(result.usageMetrics?.output_tokens).toBe(50);
+    });
+
+    it("should extract completion_tokens as output_tokens", () => {
+      const response = JSON.stringify({
+        usage: {
+          completion_tokens: 200,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "openai");
+
+      expect(result.usageMetrics).not.toBeNull();
+      expect(result.usageMetrics?.output_tokens).toBe(200);
+    });
+
+    it("should prefer input_tokens over prompt_tokens (Claude format priority)", () => {
+      const response = JSON.stringify({
+        usage: {
+          input_tokens: 500,
+          output_tokens: 300,
+          prompt_tokens: 100,
+          completion_tokens: 50,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "openai");
+
+      expect(result.usageMetrics?.input_tokens).toBe(500);
+      expect(result.usageMetrics?.output_tokens).toBe(300);
+    });
+
+    it("should handle OpenAI streaming chunk with usage in final event", () => {
+      const sse = [
+        'data: {"id":"chatcmpl-1","object":"chat.completion.chunk","created":1234567890,"model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":"Hi"}}]}',
+        "",
+        'data: {"id":"chatcmpl-1","object":"chat.completion.chunk","created":1234567890,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":33,"completion_tokens":31,"total_tokens":64}}',
+        "",
+        "data: [DONE]",
+      ].join("\n");
+
+      const result = parseUsageFromResponseText(sse, "openai");
+
+      expect(result.usageMetrics).not.toBeNull();
+      expect(result.usageMetrics?.input_tokens).toBe(33);
+      expect(result.usageMetrics?.output_tokens).toBe(31);
+    });
+
+    it("should handle OpenAI completion_tokens_details (reasoning_tokens)", () => {
+      const response = JSON.stringify({
+        usage: {
+          prompt_tokens: 66,
+          completion_tokens: 57,
+          total_tokens: 123,
+          completion_tokens_details: {
+            reasoning_tokens: 0,
+          },
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "openai-compatible");
+
+      expect(result.usageMetrics).not.toBeNull();
+      expect(result.usageMetrics?.input_tokens).toBe(66);
+      expect(result.usageMetrics?.output_tokens).toBe(57);
+    });
+  });
 });