Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions src/app/api/v1/messages/count_tokens/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CORS_HEADERS } from "@/shared/utils/cors";
import { v1CountTokensSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { estimateTokens } from "@/shared/utils/costEstimator";
import { countTextTokens } from "@/shared/utils/tiktokenCounter";
import { getExecutor } from "@omniroute/open-sse/executors/index.ts";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import { getModelInfo } from "@/sse/services/model";
Expand Down Expand Up @@ -101,31 +101,31 @@ export async function POST(request) {

function buildEstimatedCountResponse(body) {
const messages = Array.isArray(body?.messages) ? body.messages : [];
let totalChars = 0;
let inputTokens = 0;

for (const msg of messages) {
if (typeof msg?.content === "string") {
totalChars += msg.content.length;
inputTokens += countTextTokens(msg.content);
continue;
}

if (Array.isArray(msg?.content)) {
for (const part of msg.content) {
if (part?.type === "text" && typeof part.text === "string") {
totalChars += part.text.length;
inputTokens += countTextTokens(part.text);
}
}
}
}

if (typeof body?.system === "string") {
totalChars += body.system.length;
inputTokens += countTextTokens(body.system);
}
Comment on lines 121 to 123

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Na API de Messages da Anthropic, o parâmetro system pode ser fornecido tanto como uma string simples quanto como um array de blocos de conteúdo (por exemplo, blocos de texto do tipo { type: "text", text: "..." }). Atualmente, o código apenas conta os tokens se system for uma string, ignorando completamente quando ele é enviado como um array.

Podemos melhorar a robustez e a precisão da contagem local adicionando suporte para quando system for um array de blocos de texto, de forma análoga ao que já é feito para as mensagens.

  if (typeof body?.system === "string") {
    inputTokens += countTextTokens(body.system);
  } else if (Array.isArray(body?.system)) {
    for (const part of body.system) {
      if (part?.type === "text" && typeof part.text === "string") {
        inputTokens += countTextTokens(part.text);
      }
    }
  }


return new Response(
JSON.stringify({
input_tokens: totalChars > 0 ? Math.ceil(totalChars / 4) : estimateTokens(""),
source: "estimated",
input_tokens: inputTokens,
source: "local",
}),
{
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
Expand Down
21 changes: 21 additions & 0 deletions src/shared/utils/tiktokenCounter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { getEncoding, type Tiktoken } from "js-tiktoken";

let encoder: Tiktoken | null = null;

function getEncoder(): Tiktoken {
if (!encoder) encoder = getEncoding("cl100k_base");
return encoder;
}

/**
* Exact token count for a string using cl100k_base (offline, no upstream call).
* Defensive: never throws in a counting path — falls back to a char heuristic.
*/
export function countTextTokens(text: string): number {
if (!text || typeof text !== "string") return 0;
try {
return getEncoder().encode(text).length;
} catch {
return Math.ceil(text.length / 4);
}
}
Comment on lines +3 to +21

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Se a inicialização do js-tiktoken falhar (por exemplo, devido a problemas de ambiente ou dependências corrompidas), a função getEncoding lançará um erro. Atualmente, se isso acontecer, o erro será capturado, mas a função tentará inicializar o encoder novamente em cada chamada subsequente a countTextTokens. Em um loop com muitas mensagens, isso pode causar uma degradação severa de desempenho devido ao custo de repetidas tentativas de inicialização e captura de exceções.

Podemos otimizar isso introduzindo uma flag initializationFailed para evitar tentativas repetidas de inicialização após a primeira falha.

let encoder: Tiktoken | null = null;
let initializationFailed = false;

function getEncoder(): Tiktoken | null {
  if (initializationFailed) return null;
  if (!encoder) {
    try {
      encoder = getEncoding("cl100k_base");
    } catch {
      initializationFailed = true;
      return null;
    }
  }
  return encoder;
}

/**
 * Exact token count for a string using cl100k_base (offline, no upstream call).
 * Defensive: never throws in a counting path — falls back to a char heuristic.
 */
export function countTextTokens(text: string): number {
  if (!text || typeof text !== "string") return 0;
  try {
    const enc = getEncoder();
    if (enc) {
      return enc.encode(text).length;
    }
  } catch {
    // Fallback to heuristic below
  }
  return Math.ceil(text.length / 4);
}

20 changes: 16 additions & 4 deletions tests/unit/messages-count-tokens-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,20 @@ test("messages/count_tokens falls back to estimate when model is missing", async

assert.equal(response.status, 200);
const body = (await response.json()) as any;
assert.equal(body.input_tokens, 3);
assert.equal(body.source, "estimated");
assert.equal(body.input_tokens, 4); // tiktoken: "abcd"=1 + "12345678"=3
assert.equal(body.source, "local");
});

test("count_tokens fallback uses exact tiktoken count with source=local", async () => {
const req = new Request("http://localhost/v1/messages/count_tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: "hello world" }] }),
});
const res = await POST(req);
const json = (await res.json()) as any;
assert.equal(json.source, "local");
assert.equal(json.input_tokens, 2); // exact cl100k_base count, not Math.ceil(11/4)=3
});

test("messages/count_tokens falls back to estimate when real upstream count fails", async () => {
Expand All @@ -121,8 +133,8 @@ test("messages/count_tokens falls back to estimate when real upstream count fail

assert.equal(response.status, 200);
const body = (await response.json()) as any;
assert.equal(body.input_tokens, 1);
assert.equal(body.source, "estimated");
assert.equal(body.input_tokens, 1); // tiktoken: "abcd"=1
assert.equal(body.source, "local");
} finally {
globalThis.fetch = originalFetch;
}
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/tiktoken-counter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { countTextTokens } from "../../src/shared/utils/tiktokenCounter.ts";

test("countTextTokens returns exact tiktoken count for a known string", () => {
assert.equal(countTextTokens("hello world"), 2); // cl100k_base
});

test("countTextTokens handles empty and non-string safely", () => {
assert.equal(countTextTokens(""), 0);
assert.equal(countTextTokens(undefined as unknown as string), 0);
});

test("countTextTokens is additive-ish and monotonic for longer text", () => {
const short = countTextTokens("the quick brown fox");
const long = countTextTokens("the quick brown fox jumps over the lazy dog");
assert.ok(long > short);
assert.ok(short > 0);
});
Loading