-
Notifications
You must be signed in to change notification settings - Fork 192
feat(routing): cache weight via length-based estimate #2104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e9ac8db
6e2a511
8b5379d
4bbb9ca
5bbd60e
4a47bfe
22325c3
4c9c992
0cc09ec
65d3aa4
11d6eb1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,4 @@ | ||
| import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; | ||
| import { encode } from "gpt-tokenizer"; | ||
| import { HTTPException } from "hono/http-exception"; | ||
| import { streamSSE } from "hono/streaming"; | ||
|
|
||
|
|
@@ -245,6 +244,7 @@ function collapseProvidersToBestRegionPerProvider( | |
| options: { | ||
| metricsMap: Map<string, ProviderMetrics>; | ||
| isStreaming: boolean; | ||
| promptTokens?: number; | ||
| }, | ||
| ): ProviderModelMapping[] { | ||
| const providersById = new Map<string, ProviderModelMapping[]>(); | ||
|
|
@@ -1087,6 +1087,18 @@ chat.openapi(completions, async (c) => { | |
| } | ||
| } | ||
|
|
||
| // Estimate prompt tokens once so all routing decisions can reuse the | ||
| // same value (e.g. cache-support weighting kicks in for large prompts). | ||
| // Uses a cheap chars/4 heuristic — accuracy is intentionally traded | ||
| // for throughput on the gateway hot path. | ||
| let routingPromptTokens = 0; | ||
| if (messages && messages.length > 0) { | ||
| routingPromptTokens = encodeChatMessages(messages); | ||
| } | ||
| if (tools && tools.length > 0) { | ||
| routingPromptTokens += Math.round(JSON.stringify(tools).length / 4); | ||
| } | ||
|
steebchen marked this conversation as resolved.
|
||
|
|
||
| // Extract and validate source from x-source header with HTTP-Referer fallback | ||
| let source = validateSource( | ||
| c.req.header("x-source"), | ||
|
|
@@ -1498,34 +1510,9 @@ chat.openapi(completions, async (c) => { | |
| (usedProvider === "llmgateway" && usedModel === "auto") || | ||
| usedModel === "auto" | ||
| ) { | ||
| // Estimate prompt/input tokens first so auto-routing can react to large prompts | ||
| let estimatedInputTokens = 0; | ||
|
|
||
| // Estimate prompt tokens from messages | ||
| if (messages && messages.length > 0) { | ||
| try { | ||
| estimatedInputTokens = encodeChatMessages(messages); | ||
| } catch { | ||
| // Fallback to simple estimation if encoding fails | ||
| const messageTokens = messages.reduce( | ||
| (acc, m) => acc + (m.content?.length ?? 0), | ||
| 0, | ||
| ); | ||
| estimatedInputTokens = Math.max(1, Math.round(messageTokens / 4)); | ||
| } | ||
| } | ||
|
|
||
| // Add tool definitions to context estimation | ||
| if (tools && tools.length > 0) { | ||
| try { | ||
| const toolsString = JSON.stringify(tools); | ||
| const toolTokens = Math.round(toolsString.length / 4); | ||
| estimatedInputTokens += toolTokens; | ||
| } catch { | ||
| // Fallback estimation for tools | ||
| estimatedInputTokens += tools.length * 100; // Rough estimate per tool | ||
| } | ||
| } | ||
| // Reuse the prompt-token estimate computed earlier so auto-routing can | ||
| // react to large prompts when picking a model. | ||
| const estimatedInputTokens = routingPromptTokens; | ||
|
|
||
| // Estimate the full context needed based on the request | ||
| let requiredContextSize = estimatedInputTokens; | ||
|
|
@@ -1744,13 +1731,18 @@ chat.openapi(completions, async (c) => { | |
| { | ||
| metricsMap, | ||
| isStreaming: stream, | ||
| promptTokens: routingPromptTokens, | ||
| }, | ||
| ); | ||
|
|
||
| const cheapestResult = getCheapestFromAvailableProviders( | ||
| providerAgnosticSelectedProviders, | ||
| selectedModel, | ||
| { metricsMap, isStreaming: stream }, | ||
| { | ||
| metricsMap, | ||
| isStreaming: stream, | ||
| promptTokens: routingPromptTokens, | ||
| }, | ||
| ); | ||
|
|
||
| if (cheapestResult) { | ||
|
|
@@ -1910,6 +1902,7 @@ chat.openapi(completions, async (c) => { | |
| { | ||
| metricsMap, | ||
| isStreaming: stream, | ||
| promptTokens: routingPromptTokens, | ||
| }, | ||
| ); | ||
|
|
||
|
|
@@ -2094,6 +2087,7 @@ chat.openapi(completions, async (c) => { | |
| { | ||
| metricsMap: allMetricsMap, | ||
| isStreaming: stream, | ||
| promptTokens: routingPromptTokens, | ||
| }, | ||
| ); | ||
|
|
||
|
|
@@ -2231,7 +2225,11 @@ chat.openapi(completions, async (c) => { | |
| collapseProvidersToBestRegionPerProvider( | ||
| availableModelProviders, | ||
| modelWithPricing, | ||
| { metricsMap: allMetricsMap, isStreaming: stream }, | ||
| { | ||
| metricsMap: allMetricsMap, | ||
| isStreaming: stream, | ||
| promptTokens: routingPromptTokens, | ||
| }, | ||
| ); | ||
|
|
||
| // Filter to only providers with better uptime than the original | ||
|
|
@@ -2256,7 +2254,11 @@ chat.openapi(completions, async (c) => { | |
| const cheapestResult = getCheapestFromAvailableProviders( | ||
| betterUptimeProviders, | ||
| modelWithPricing, | ||
| { metricsMap: allMetricsMap, isStreaming: stream }, | ||
| { | ||
| metricsMap: allMetricsMap, | ||
| isStreaming: stream, | ||
| promptTokens: routingPromptTokens, | ||
| }, | ||
| ); | ||
|
|
||
| // Get price info for the original requested provider to include in scores | ||
|
|
@@ -2433,13 +2435,21 @@ chat.openapi(completions, async (c) => { | |
| collapseProvidersToBestRegionPerProvider( | ||
| routingCandidates, | ||
| modelWithPricing, | ||
| { metricsMap, isStreaming: stream }, | ||
| { | ||
| metricsMap, | ||
| isStreaming: stream, | ||
| promptTokens: routingPromptTokens, | ||
| }, | ||
| ); | ||
|
|
||
| const cheapestResult = getCheapestFromAvailableProviders( | ||
| providerAgnosticCandidates, | ||
| modelWithPricing, | ||
| { metricsMap, isStreaming: stream }, | ||
| { | ||
| metricsMap, | ||
| isStreaming: stream, | ||
| promptTokens: routingPromptTokens, | ||
| }, | ||
| ); | ||
|
|
||
| if (cheapestResult) { | ||
|
|
@@ -2606,6 +2616,7 @@ chat.openapi(completions, async (c) => { | |
| { | ||
| metricsMap, | ||
| isStreaming: stream, | ||
| promptTokens: routingPromptTokens, | ||
| }, | ||
| ); | ||
|
|
||
|
|
@@ -6866,27 +6877,8 @@ chat.openapi(completions, async (c) => { | |
| imageTokens = 258 + Math.ceil(imageByteSize / 750); | ||
| } | ||
|
|
||
| // Skip expensive token encoding for image responses - use simple estimation | ||
| // Token encoding on large base64 content causes CPU spikes | ||
| if (imageByteSize > 0) { | ||
| const textTokens = estimateTokensFromContent(fullContent); | ||
| calculatedCompletionTokens = textTokens + imageTokens; | ||
| } else { | ||
| try { | ||
| const textTokens = fullContent | ||
| ? encode(JSON.stringify(fullContent)).length | ||
| : 0; | ||
| calculatedCompletionTokens = textTokens + imageTokens; | ||
| } catch (error) { | ||
| // Fallback to simple estimation if encoding fails | ||
| logger.error( | ||
| "Failed to encode completion text in streaming", | ||
| error instanceof Error ? error : new Error(String(error)), | ||
| ); | ||
| const textTokens = estimateTokensFromContent(fullContent); | ||
| calculatedCompletionTokens = textTokens + imageTokens; | ||
| } | ||
| } | ||
| const textTokens = estimateTokensFromContent(fullContent); | ||
| calculatedCompletionTokens = textTokens + imageTokens; | ||
| } | ||
|
|
||
| calculatedTotalTokens = | ||
|
|
@@ -6896,17 +6888,8 @@ chat.openapi(completions, async (c) => { | |
| // Estimate reasoning tokens if not provided but reasoning content exists | ||
| let calculatedReasoningTokens = reasoningTokens; | ||
| if (!reasoningTokens && fullReasoningContent) { | ||
| try { | ||
| calculatedReasoningTokens = encode(fullReasoningContent).length; | ||
| } catch (error) { | ||
| // Fallback to simple estimation if encoding fails | ||
| logger.error( | ||
| "Failed to encode reasoning text in streaming", | ||
| error instanceof Error ? error : new Error(String(error)), | ||
| ); | ||
| calculatedReasoningTokens = | ||
| estimateTokensFromContent(fullReasoningContent); | ||
| } | ||
| calculatedReasoningTokens = | ||
| estimateTokensFromContent(fullReasoningContent); | ||
|
Comment on lines
6889
to
+6892
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thread the estimated reasoning tokens through the downstream accounting.
Also applies to: 8940-8942 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| if ( | ||
|
|
@@ -8962,16 +8945,7 @@ chat.openapi(completions, async (c) => { | |
| // Estimate reasoning tokens if not provided but reasoning content exists | ||
| let calculatedReasoningTokens = reasoningTokens; | ||
| if (!reasoningTokens && reasoningContent) { | ||
| try { | ||
| calculatedReasoningTokens = encode(reasoningContent).length; | ||
| } catch (error) { | ||
| // Fallback to simple estimation if encoding fails | ||
| logger.error( | ||
| "Failed to encode reasoning text", | ||
| error instanceof Error ? error : new Error(String(error)), | ||
| ); | ||
| calculatedReasoningTokens = estimateTokensFromContent(reasoningContent); | ||
| } | ||
| calculatedReasoningTokens = estimateTokensFromContent(reasoningContent); | ||
| } | ||
| const costs = await calculateCosts( | ||
| usedModel, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,9 @@ | ||
| import { estimateTokensFromText } from "@llmgateway/shared"; | ||
|
|
||
| /** | ||
| * Estimates tokens from content length using simple division | ||
| * Estimates tokens from content length using a chars/4 heuristic. Backed by | ||
| * the shared text-only estimator. | ||
| */ | ||
| export function estimateTokensFromContent(content: string): number { | ||
| if (!content) { | ||
| return 0; | ||
| } | ||
| return Math.max(1, Math.round(content.length / 4)); | ||
| return estimateTokensFromText(content); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,10 @@ | ||
| import { encodeChat } from "gpt-tokenizer"; | ||
|
|
||
| import { logger } from "@llmgateway/logger"; | ||
|
|
||
| import { DEFAULT_TOKENIZER_MODEL } from "./types.js"; | ||
| import { estimateChatMessageTokens } from "@llmgateway/shared"; | ||
|
|
||
| /** | ||
| * Converts a message content value (string, array of content parts, null, or | ||
| * undefined) to a plain string suitable for the gpt-tokenizer library. | ||
| * undefined) to a plain string. Used by call sites that need a flat string | ||
| * (e.g. for cost estimation) — not for token counting; see | ||
| * `encodeChatMessages` for that. | ||
| */ | ||
| export function messageContentToString( | ||
| content: string | unknown[] | null | undefined, | ||
|
|
@@ -21,36 +19,12 @@ export function messageContentToString( | |
| } | ||
|
|
||
| /** | ||
| * Encodes an array of chat messages and returns the token count. Handles | ||
| * messages whose content may be a string, an array of content parts, null, or | ||
| * undefined – all of which are valid shapes in the OpenAI chat format but | ||
| * would otherwise crash gpt-tokenizer. | ||
| * Rough length-based prompt-token estimate for a chat message array. | ||
| * | ||
| * Backed by the shared `estimateChatMessageTokens` helper, which only counts | ||
| * text and ignores multimodal parts (image_url, file, etc.). Image input | ||
| * billing is handled separately in costs.ts. | ||
| */ | ||
| export function encodeChatMessages(messages: any[]): number { | ||
| try { | ||
| const chatMessages = messages.map((m) => ({ | ||
| role: m.role as "user" | "assistant" | "system" | undefined, | ||
| content: messageContentToString(m.content), | ||
| ...(m.name !== null && m.name !== undefined && { name: m.name }), | ||
| })); | ||
| return encodeChat(chatMessages, DEFAULT_TOKENIZER_MODEL).length; | ||
| } catch (error) { | ||
| logger.error("Failed to encode chat messages", { | ||
| error: error instanceof Error ? error.message : String(error), | ||
| messageCount: messages.length, | ||
| messageRoles: messages.map((m) => m.role), | ||
| messageContentTypes: messages.map((m) => typeof m.content), | ||
| }); | ||
| // Fallback: rough 4-chars-per-token estimate | ||
| return Math.max( | ||
| 1, | ||
| Math.round( | ||
| messages.reduce( | ||
| (acc: number, m: any) => | ||
| acc + messageContentToString(m.content).length, | ||
| 0, | ||
| ) / 4, | ||
| ), | ||
| ); | ||
| } | ||
| return estimateChatMessageTokens(messages); | ||
| } | ||
|
Comment on lines
28
to
30
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify current `any[]` usage and inspect encodeChatMessages call sites before tightening types.
rg -n --type=ts -C2 'export function encodeChatMessages\(messages:\s*any\[\]\)'
rg -n --type=ts -C2 '\bencodeChatMessages\('Repository: theopenco/llmgateway Length of output: 2749 🏁 Script executed: #!/bin/bash
# Check the actual types of messages at call sites
# 1. Check costs.ts to see type of fullOutput.messages
head -160 apps/gateway/src/lib/costs.ts | tail -20
# 2. Check chat.ts to see type of messages parameter where encodeChatMessages is called
grep -B20 "encodeChatMessages(messages)" apps/gateway/src/chat/chat.ts | head -40
# 3. Look for existing message type definitions
rg -n "type.*Message|interface.*Message" --type=ts | grep -v node_modules | head -20
# 4. Check estimate-tokens.ts
rg -B5 "encodeChatMessages" apps/gateway/src/chat/tools/estimate-tokens.ts
# 5. Check the estimateChatMessageTokens function signature
rg -A10 "function estimateChatMessageTokens" --type=tsRepository: theopenco/llmgateway Length of output: 5181 🏁 Script executed: #!/bin/bash
# Get the MessageLike interface definition
cat -n packages/shared/src/token-estimate.ts | head -50
# Check if encodeChatMessages is defined in tokenizer.ts with more context
cat -n apps/gateway/src/chat/tools/tokenizer.ts
# Check the types imported/used in chat.ts for the messages parameter
grep -B10 "function.*messages" apps/gateway/src/chat/chat.ts | head -30
# Check if there are type definitions for fullOutput in costs.ts
rg -B5 "fullOutput" apps/gateway/src/lib/costs.ts | head -30Repository: theopenco/llmgateway Length of output: 4083 🏁 Script executed: #!/bin/bash
# Check if MessageLike is exported from the shared package
rg -n "export.*MessageLike|export.*interface.*MessageLike" packages/shared/src/
# Check the exports in token-estimate.ts
grep -n "export" packages/shared/src/token-estimate.ts
# Check what's currently imported in tokenizer.ts
head -10 apps/gateway/src/chat/tools/tokenizer.ts
# Check if `@llmgateway/shared` is available as a dependency in gateway
grep -n "@llmgateway/shared" apps/gateway/package.json
# Check the full token-estimate.ts to see if MessageLike is exported
cat packages/shared/src/token-estimate.tsRepository: theopenco/llmgateway Length of output: 2476 Replace Line 28 uses Suggested type-safe change+type EstimationContentPart = {
+ type?: string;
+ text?: string;
+};
+
+type EstimationMessage = {
+ content?: string | EstimationContentPart[] | null;
+};
+
-export function encodeChatMessages(messages: any[]): number {
+export function encodeChatMessages(messages: EstimationMessage[]): number {
return estimateChatMessageTokens(messages);
}🤖 Prompt for AI Agents |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The docs say cache support is exposed as
cacheSupported"on the routing metadata", but in code it’s added on each entry ofmetadata.providerScores(and not as a top-levelRoutingMetadata.cacheSupported). Please clarify the exact field path (e.g.routingMetadata.providerScores[].cacheSupported) to avoid consumers looking for a non-existent top-level property.