Skip to content

Commit 7f880f4

Browse files
authored
feat: add function calling token counting (#83)
- add a function-calling aware token counting API to `GptEncoding` - introduce shared fixtures and a regression test suite that covers OpenAI function request shapes - expose `countChatCompletionTokens` only on function-calling model entrypoints via the code generator - extract the function calling token counting helpers and fixtures into dedicated modules with named token constants
1 parent 0cb0870 commit 7f880f4

62 files changed

Lines changed: 1090 additions & 23 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,51 @@ const text = 'Hello, world!'
272272
const tokenCount = countTokens(text)
273273
```
274274

275+
### `countChatCompletionTokens(request: ChatCompletionRequest): number`
276+
277+
Counts the tokens that a function-calling chat completion request will consume, including message overhead, optional function definitions, and pinned function calls. This helper is only available on models that support the `function_calling` feature.
278+
279+
Example:
280+
281+
```typescript
282+
import {
283+
countChatCompletionTokens,
284+
type ChatCompletionRequest,
285+
} from 'gpt-tokenizer/model/gpt-4o'
286+
287+
const request: ChatCompletionRequest = {
288+
messages: [
289+
{ role: 'system', content: 'You are a helpful assistant.' },
290+
{ role: 'user', content: 'Find the weather for San Francisco.' },
291+
],
292+
functions: [
293+
{
294+
name: 'get_weather',
295+
description: 'Look up the weather for a city.',
296+
parameters: {
297+
type: 'object',
298+
required: ['city'],
299+
properties: {
300+
city: { type: 'string' },
301+
unit: { type: 'string', enum: ['celsius', 'fahrenheit'] },
302+
},
303+
},
304+
},
305+
],
306+
}
307+
308+
const promptTokenEstimate = countChatCompletionTokens(request)
309+
```
310+
311+
You can also access the helper from the module's default export:
312+
313+
```typescript
314+
import gpt4o from 'gpt-tokenizer/model/gpt-4o'
315+
316+
// Reuse the `request` defined above
317+
const tokenCount = gpt4o.countChatCompletionTokens?.(request)
318+
```
319+
275320
### `encodeChat(chat: ChatMessage[], model?: ModelName, encodeOptions?: EncodeOptions): number[]`
276321

277322
Encodes the given chat into a sequence of tokens. The optional `encodeOptions` parameter lets you configure special token handling.

src/GptEncoding.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,24 @@ import * as modelsMap from './modelsMap.js'
1818
import { resolveEncoding } from './resolveEncoding.js'
1919
import { EndOfText } from './specialTokens.js'
2020

21+
describe('generated model exports', () => {
22+
test('gpt-5 re-exports the chat token counter helper', async () => {
23+
const mod = await import('./model/gpt-5.js')
24+
const encoding = mod.default
25+
26+
expect('countChatCompletionTokens' in mod).toBe(true)
27+
expect(mod.countChatCompletionTokens).toBe(
28+
encoding.countChatCompletionTokens,
29+
)
30+
})
31+
32+
test('gpt-3.5-turbo-0613 omits the chat token counter helper', async () => {
33+
const mod = await import('./model/gpt-3.5-turbo-0613.js')
34+
35+
expect('countChatCompletionTokens' in mod).toBe(false)
36+
})
37+
})
38+
2139
const sharedResults = {
2240
space: [220],
2341
tab: [197],

src/GptEncoding.ts

Lines changed: 48 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
/* eslint-disable no-param-reassign */
33
import { BytePairEncodingCore, decoder } from './BytePairEncodingCore.js'
44
import { ALL_SPECIAL_TOKENS } from './constants.js'
5+
import {
6+
type ChatCompletionRequest,
7+
type ChatMessage,
8+
type EncodeChatOptions,
9+
type HarmonyTerminator,
10+
computeChatCompletionTokenCount,
11+
} from './functionCalling.js'
512
import {
613
type ChatModelName,
714
type ChatParameters,
@@ -38,6 +45,25 @@ import {
3845
import { endsWithIncompleteUtfPairSurrogate } from './utfUtil.js'
3946
import { getMaxValueFromMap, getSpecialTokenRegex } from './util.js'
4047

48+
export type {
49+
ChatCompletionArrayProperty,
50+
ChatCompletionBooleanProperty,
51+
ChatCompletionFunctionCallOption,
52+
ChatCompletionFunctionDefinition,
53+
ChatCompletionFunctionParameters,
54+
ChatCompletionFunctionProperty,
55+
ChatCompletionFunctionType,
56+
ChatCompletionNullProperty,
57+
ChatCompletionNumberProperty,
58+
ChatCompletionObjectProperty,
59+
ChatCompletionRequest,
60+
ChatCompletionStringProperty,
61+
ChatMessage,
62+
ChatMessageFunctionCall,
63+
EncodeChatOptions,
64+
HarmonyTerminator,
65+
} from './functionCalling.js'
66+
4167
export interface CostEstimate {
4268
input?: number
4369
output?: number
@@ -66,28 +92,6 @@ export interface EncodeOptions {
6692
disallowedSpecial?: Set<string> | typeof ALL_SPECIAL_TOKENS
6793
}
6894

69-
export type HarmonyTerminator = '<|end|>' | '<|return|>' | '<|call|>'
70-
71-
export interface ChatMessage {
72-
role?: 'system' | 'user' | 'assistant' | 'developer' | (string & {})
73-
name?: string
74-
content: string
75-
/** Harmony-only: channel label such as `analysis`, `commentary`, or `final`. */
76-
channel?: string
77-
/** Harmony-only: recipient metadata, e.g. `functions.get_weather` or `assistant`. */
78-
recipient?: string
79-
/** Controls where the recipient metadata is rendered in Harmony headers. Defaults to `channel`. */
80-
recipientPlacement?: 'role' | 'channel'
81-
/** Harmony-only: constraint label, e.g. `json`. */
82-
constraint?: string
83-
/** Harmony-only: overrides the closing token, defaults to `<|end|>`. */
84-
terminator?: HarmonyTerminator
85-
}
86-
87-
export interface EncodeChatOptions {
88-
primeWithAssistantResponse?: string
89-
}
90-
9195
interface SpecialTokenConfig {
9296
allowedSpecial: Set<string> | undefined
9397
regexPattern: RegExp | undefined
@@ -109,6 +113,8 @@ export class GptEncoding {
109113
private defaultSpecialTokenConfig: SpecialTokenConfig
110114
private chatFormatter: ChatFormatter
111115

116+
countChatCompletionTokens?: (request: ChatCompletionRequest) => number
117+
112118
readonly vocabularySize: number
113119

114120
private constructor({
@@ -169,6 +175,10 @@ export class GptEncoding {
169175
this.setMergeCacheSize = this.setMergeCacheSize.bind(this)
170176
this.clearMergeCache = this.clearMergeCache.bind(this)
171177
this.estimateCost = this.estimateCost.bind(this)
178+
if (modelSpec?.supported_features?.includes('function_calling')) {
179+
this.countChatCompletionTokens =
180+
this.countChatCompletionTokensInternal.bind(this)
181+
}
172182
this.modelName = modelName
173183
this.modelSpec = modelSpec
174184
this.chatFormatter = chatFormatter ?? 'chatml'
@@ -522,6 +532,22 @@ export class GptEncoding {
522532
return count
523533
}
524534

535+
private countStringTokens(text: string): number {
536+
if (!text) {
537+
return 0
538+
}
539+
540+
return this.bytePairEncodingCoreProcessor.countNative(text)
541+
}
542+
543+
private countChatCompletionTokensInternal(
544+
request: ChatCompletionRequest,
545+
): number {
546+
return computeChatCompletionTokenCount(request, (text) =>
547+
this.countStringTokens(text),
548+
)
549+
}
550+
525551
setMergeCacheSize(size: number): void {
526552
this.bytePairEncodingCoreProcessor.setMergeCacheSize(size)
527553
}

src/codegen/generateByModel.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
DEFAULT_ENCODING,
1111
modelToEncodingMap,
1212
} from '../mapping.js'
13+
import type { Feature } from '../modelTypes.js'
1314
import * as models from '../models.js'
1415

1516
// eslint-disable-next-line no-underscore-dangle
@@ -71,7 +72,15 @@ await Promise.all(
7172
'',
7273
]
7374

74-
const baseContent = isChatModel
75+
const supportedFeatures = (
76+
modelData as { supported_features?: readonly Feature[] }
77+
).supported_features
78+
79+
const supportsFunctionCalling =
80+
Array.isArray(supportedFeatures) &&
81+
supportedFeatures.includes('function_calling')
82+
83+
let baseContent = isChatModel
7584
? template
7685
.replace(
7786
`getEncodingApi('cl100k_base', () => bpeRanks)`,
@@ -90,6 +99,14 @@ export { default } from '../encoding/${encoding}.js'
9099
export * from '../encoding/${encoding}.js'
91100
`
92101

102+
if (isChatModel && supportsFunctionCalling) {
103+
const snippet = ' encodeChat,\n encodeChatGenerator,\n'
104+
const replacement =
105+
' encodeChat,\n countChatCompletionTokens,\n encodeChatGenerator,\n'
106+
baseContent = baseContent.replace(snippet, replacement)
107+
baseContent = baseContent.replace(snippet, replacement)
108+
}
109+
93110
const content = insertHeaderAfterDirectives(baseContent, headerLines)
94111
await fs.writeFile(
95112
path.join(__dirname, `../model/${modelName}.ts`),

0 commit comments

Comments
 (0)