mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
💸 feat: Per-Agent Endpoint Token Config in Multi-Endpoint Billing (#13738)
* 💸 feat: Per-Agent Endpoint Token Config in Multi-Endpoint Billing
Price each collected/emitted usage item with the producing agent's resolved
endpoint token config, instead of the primary agent's for the whole graph.
Previously AgentClient.recordCollectedUsage and the subagent usage emitter used
a single this.options.endpointTokenConfig (the primary's) for every usage item.
A connected agent or subagent on a different custom endpoint that shares a model
id with an entry in the primary's tokenConfig was therefore mis-priced (a model
absent from it already fell back to the built-in rate map — no regression).
- Tag each usage with its producing agent: ModelEndHandler stamps
usage.agentId = agentContext.agentId; createSubagentUsageSink stamps the
child's subagentAgentId (UsageMetadata gains an optional agentId).
- buildAgentToolContext retains endpointTokenConfig so initialize.js can build
an agentId -> endpointTokenConfig map from agentToolContexts (the one map that
holds every agent, including pure subagents pruned from agentConfigs).
- AgentClient.resolveAgentEndpointTokenConfig(usage) looks up that map by
agentId, falling back to the primary config; used by both the billing path
(new optional resolveEndpointTokenConfig on recordCollectedUsage) and the
subagent cost emitter.
- recordCollectedUsage's resolver is optional and falls back to the batch
endpointTokenConfig, so the shared responses.js/openai.js call sites are
unchanged.
- Tests: two-endpoint graph with a colliding model id prices per-agent; resolver
nullish falls back to batch; subagent sink tags the child agent id.
* fix: Align emit-path cost with per-agent billing; honor known-agent built-in pricing
Addresses Codex review on the per-agent endpoint token config:
- Emit path (callbacks.js) now prices each on_token_usage event with the
producing agent's config (resolved via usageCost.resolveEndpointTokenConfig),
so streamed/persisted metadata.usage.cost matches the per-agent balance
transaction. The agentId tag is resolved server-side and stripped from the
emitted/persisted payload.
- Resolver (resolveAgentTokenConfig) now treats a known agent's config as
authoritative, including undefined → built-in pricing, so a known non-custom
agent in a custom-primary graph is no longer charged the primary's rates.
Only untagged/unknown usage falls back to the primary config.
- endpointTokenConfigByAgentId records every known agent (value may be
undefined) so the resolver distinguishes known-no-rates from unknown.
This commit is contained in:
parent
b03b2a0a29
commit
4ee68d5240
9 changed files with 347 additions and 5 deletions
|
|
@ -150,6 +150,45 @@ describe('ModelEndHandler — Vertex thoughtSignature capture (issue #13006 foll
|
|||
expect(collectedThoughtSignatures).toEqual({});
|
||||
});
|
||||
|
||||
it('tags the producing agent on collected + emitted usage for per-endpoint pricing', async () => {
|
||||
const collectedUsage = [];
|
||||
const emitUsage = jest.fn();
|
||||
const handler = new ModelEndHandler(collectedUsage, null, emitUsage);
|
||||
const graph = {
|
||||
getAgentContext: () => ({
|
||||
provider: 'openai',
|
||||
agentId: 'agent_sub',
|
||||
clientOptions: { model: 'gpt-4' },
|
||||
}),
|
||||
};
|
||||
|
||||
await handler.handle(
|
||||
'on_chat_model_end',
|
||||
{ output: { usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } } },
|
||||
{ ls_model_name: 'gpt-4', run_id: 'r1', user_id: 'u1' },
|
||||
graph,
|
||||
);
|
||||
|
||||
expect(collectedUsage[0].agentId).toBe('agent_sub');
|
||||
expect(emitUsage).toHaveBeenCalledWith(expect.objectContaining({ agentId: 'agent_sub' }));
|
||||
});
|
||||
|
||||
it('leaves usage untagged when the graph context has no agentId (single-endpoint)', async () => {
|
||||
const collectedUsage = [];
|
||||
const emitUsage = jest.fn();
|
||||
const handler = new ModelEndHandler(collectedUsage, null, emitUsage);
|
||||
|
||||
await handler.handle(
|
||||
'on_chat_model_end',
|
||||
{ output: { usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } } },
|
||||
{ ls_model_name: 'gemini-3.1-flash-lite-preview', run_id: 'r1', user_id: 'u1' },
|
||||
buildGraph(),
|
||||
);
|
||||
|
||||
expect(collectedUsage[0].agentId).toBeUndefined();
|
||||
expect(emitUsage).toHaveBeenCalledWith(expect.objectContaining({ agentId: undefined }));
|
||||
});
|
||||
|
||||
it('throws when collectedUsage is not an array (existing contract)', () => {
|
||||
expect(() => new ModelEndHandler(null)).toThrow('collectedUsage must be an array');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const {
|
|||
const {
|
||||
GenerationJobManager,
|
||||
aggregateEmittedUsage,
|
||||
resolveAgentTokenConfig,
|
||||
buildPersistedContextUsage,
|
||||
} = require('@librechat/api');
|
||||
const { getDefaultHandlers } = require('~/server/controllers/agents/callbacks');
|
||||
|
|
@ -301,6 +302,61 @@ describe('usage events through the real agents pipeline', () => {
|
|||
expect(usage.cost).toBeCloseTo(usageEmitSink.reduce((sum, e) => sum + e.cost, 0));
|
||||
});
|
||||
|
||||
test('emit path prices each call by its producing agent and strips the agentId tag', () => {
|
||||
const res = createMockRes();
|
||||
const usageEmitSink = [];
|
||||
/** Two endpoints share a model id but bill at different rates. */
|
||||
const primaryConfig = { 'gpt-4': { prompt: 0.01, completion: 0.03, context: 8192 } };
|
||||
const subagentConfig = { 'gpt-4': { prompt: 0.05, completion: 0.15, context: 8192 } };
|
||||
const byAgentId = new Map([
|
||||
['primary', primaryConfig],
|
||||
['sub', subagentConfig],
|
||||
]);
|
||||
const usageCost = {
|
||||
enabled: true,
|
||||
endpointTokenConfig: primaryConfig,
|
||||
pricing: {
|
||||
getMultiplier: ({ tokenType, model, endpointTokenConfig }) =>
|
||||
endpointTokenConfig?.[model]?.[tokenType] ?? 0,
|
||||
getCacheMultiplier: () => 0,
|
||||
},
|
||||
resolveEndpointTokenConfig: (usage) =>
|
||||
resolveAgentTokenConfig({ agentId: usage?.agentId, byAgentId, fallback: primaryConfig }),
|
||||
};
|
||||
|
||||
const { aggregateContent } = createContentAggregator();
|
||||
const handlers = getDefaultHandlers({
|
||||
res,
|
||||
aggregateContent,
|
||||
toolEndCallback: () => {},
|
||||
collectedUsage: [],
|
||||
usageEmitSink,
|
||||
usageCost,
|
||||
});
|
||||
/** The CHAT_MODEL_END handler's emitUsage IS the real emitTokenUsage closure. */
|
||||
const emitUsage = handlers[GraphEvents.CHAT_MODEL_END].emitUsage;
|
||||
const call = { model: 'gpt-4', input_tokens: 100, output_tokens: 50, total_tokens: 150 };
|
||||
emitUsage({ ...call, agentId: 'sub' });
|
||||
emitUsage({ ...call, agentId: 'primary' });
|
||||
|
||||
const events = res.events.filter((e) => e.event === 'on_token_usage');
|
||||
expect(events).toHaveLength(2);
|
||||
/** agentId is an internal pricing tag — never streamed to the client nor
|
||||
* folded into the persisted rollup. */
|
||||
for (const e of events) {
|
||||
expect(e.data.agentId).toBeUndefined();
|
||||
}
|
||||
for (const entry of usageEmitSink) {
|
||||
expect(entry.agentId).toBeUndefined();
|
||||
}
|
||||
/** Same tokens + model id, but the subagent endpoint's higher rates price
|
||||
* its call above the primary — proving per-agent emit pricing. The 5× ratio
|
||||
* ((100·0.05+50·0.15)/(100·0.01+50·0.03)) is scale-independent of credit units. */
|
||||
expect(events[1].data.cost).toBeGreaterThan(0);
|
||||
expect(events[0].data.cost).toBeGreaterThan(events[1].data.cost);
|
||||
expect(events[0].data.cost / events[1].data.cost).toBeCloseTo(5);
|
||||
});
|
||||
|
||||
test('persists usage and context snapshot for resume via GenerationJobManager', async () => {
|
||||
const streamId = `usage-e2e-stream-${Date.now()}`;
|
||||
await GenerationJobManager.createJob(streamId, 'user-1', 'convo-1');
|
||||
|
|
|
|||
|
|
@ -109,6 +109,11 @@ class ModelEndHandler {
|
|||
if (agentContext.provider) {
|
||||
usage.provider = agentContext.provider;
|
||||
}
|
||||
/** Tag the producing agent so multi-endpoint graphs can price each call
|
||||
* with its own endpoint token config (recordCollectedUsage resolver). */
|
||||
if (agentContext.agentId) {
|
||||
usage.agentId = agentContext.agentId;
|
||||
}
|
||||
|
||||
let taggedUsage = markSummarizationUsage(usage, metadata);
|
||||
/** Hidden intermediate sequential-agent calls are billed but never shown.
|
||||
|
|
@ -144,6 +149,9 @@ class ModelEndHandler {
|
|||
model: taggedUsage.model,
|
||||
provider: taggedUsage.provider,
|
||||
usage_type: taggedUsage.usage_type,
|
||||
/** Producing agent for per-endpoint pricing; consumed by the emit
|
||||
* cost resolver and not included in the emitted/persisted payload. */
|
||||
agentId: taggedUsage.agentId,
|
||||
runId: metadata?.run_id,
|
||||
/** Per-run sequence so identical payloads from distinct calls
|
||||
* stay distinguishable during resume dedupe */
|
||||
|
|
@ -308,13 +316,19 @@ function getDefaultHandlers({
|
|||
* of re-deriving from base rates.
|
||||
* @param {Record<string, unknown>} data
|
||||
*/
|
||||
const emitTokenUsage = (data) => {
|
||||
const emitTokenUsage = ({ agentId, ...data }) => {
|
||||
let payload = data;
|
||||
if (usageCost?.enabled === true && usageCost.pricing) {
|
||||
try {
|
||||
/** Price with the producing agent's config (multi-endpoint graphs) so
|
||||
* the streamed/persisted cost matches the per-agent balance transaction;
|
||||
* `agentId` is resolved here, not forwarded to the client or rollup. */
|
||||
const endpointTokenConfig = usageCost.resolveEndpointTokenConfig
|
||||
? usageCost.resolveEndpointTokenConfig({ agentId })
|
||||
: usageCost.endpointTokenConfig;
|
||||
payload = {
|
||||
...data,
|
||||
cost: computeUsageCostUSD(data, usageCost.pricing, usageCost.endpointTokenConfig),
|
||||
cost: computeUsageCostUSD(data, usageCost.pricing, endpointTokenConfig),
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn('[getDefaultHandlers] Failed to compute usage cost', err);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const {
|
|||
sendEvent,
|
||||
computeUsageCostUSD,
|
||||
aggregateEmittedUsage,
|
||||
resolveAgentTokenConfig,
|
||||
buildPersistedContextUsage,
|
||||
createSubagentUsageSink,
|
||||
isDeepSeekReasoningProvider,
|
||||
|
|
@ -886,6 +887,24 @@ class AgentClient extends BaseClient {
|
|||
return Object.keys(metadata).length > 0 ? metadata : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the endpoint token config for a usage item by its producing agent
|
||||
* (multi-endpoint graphs: connected agents + subagents). A known agent's
|
||||
* config is authoritative — including `undefined`, which prices with built-in
|
||||
* rates (e.g. a non-custom agent in a custom-primary graph). Only an
|
||||
* untagged/unknown agent falls back to the primary config, so single-endpoint
|
||||
* graphs are unchanged.
|
||||
* @param {UsageMetadata} usage
|
||||
* @returns {import('@librechat/api').EndpointTokenConfig | undefined}
|
||||
*/
|
||||
resolveAgentEndpointTokenConfig(usage) {
|
||||
return resolveAgentTokenConfig({
|
||||
agentId: usage?.agentId,
|
||||
byAgentId: this.options.endpointTokenConfigByAgentId,
|
||||
fallback: this.options.endpointTokenConfig,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} params
|
||||
* @param {string} [params.model]
|
||||
|
|
@ -918,6 +937,7 @@ class AgentClient extends BaseClient {
|
|||
balance,
|
||||
transactions,
|
||||
endpointTokenConfig: this.options.endpointTokenConfig,
|
||||
resolveEndpointTokenConfig: (usage) => this.resolveAgentEndpointTokenConfig(usage),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -950,7 +970,6 @@ class AgentClient extends BaseClient {
|
|||
return undefined;
|
||||
}
|
||||
const includeCost = appConfig?.interfaceConfig?.contextCost === true;
|
||||
const endpointTokenConfig = this.options.endpointTokenConfig;
|
||||
return (usage) => {
|
||||
const data = {
|
||||
input_tokens: usage.input_tokens,
|
||||
|
|
@ -963,11 +982,13 @@ class AgentClient extends BaseClient {
|
|||
runId: this.responseMessageId,
|
||||
/** Unique per collected entry (post-push length) for resume dedupe */
|
||||
seq: this.collectedUsage.length,
|
||||
/** Price with the SUBAGENT's own endpoint token config (its endpoint may
|
||||
* differ from the parent's); `usage.agentId` is tagged by the sink. */
|
||||
cost: includeCost
|
||||
? computeUsageCostUSD(
|
||||
usage,
|
||||
{ getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier },
|
||||
endpointTokenConfig,
|
||||
this.resolveAgentEndpointTokenConfig(usage),
|
||||
)
|
||||
: undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const {
|
|||
GenerationJobManager,
|
||||
getCustomEndpointConfig,
|
||||
discoverConnectedAgents,
|
||||
resolveAgentTokenConfig,
|
||||
resolveAgentScopedSkillIds,
|
||||
resolveModelSpecSkillIds,
|
||||
buildAgentContextAttachmentsByAgentId,
|
||||
|
|
@ -904,6 +905,28 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
})
|
||||
: undefined;
|
||||
|
||||
/** Per-agent resolved endpoint token config, keyed by agent id. Built from
|
||||
* `agentToolContexts` (the one map holding every agent, including pure
|
||||
* subagents pruned from `agentConfigs`) so usage billed/emitted for a
|
||||
* connected or subagent on a different custom endpoint is priced with THAT
|
||||
* agent's configured rates instead of the primary's. Every known agent is
|
||||
* recorded — even with an `undefined` config — so the resolver can tell a
|
||||
* known non-custom agent (built-in pricing) from an untagged/unknown one
|
||||
* (primary fallback).
|
||||
* @type {Map<string, import('@librechat/api').EndpointTokenConfig | undefined>} */
|
||||
const endpointTokenConfigByAgentId = new Map();
|
||||
for (const [agentId, ctx] of agentToolContexts) {
|
||||
endpointTokenConfigByAgentId.set(agentId, ctx?.endpointTokenConfig);
|
||||
}
|
||||
/** Price emitted usage per producing agent too, so the streamed/persisted
|
||||
* `metadata.usage.cost` matches the per-agent balance transaction. */
|
||||
usageCost.resolveEndpointTokenConfig = (usage) =>
|
||||
resolveAgentTokenConfig({
|
||||
agentId: usage?.agentId,
|
||||
byAgentId: endpointTokenConfigByAgentId,
|
||||
fallback: usageCost.endpointTokenConfig,
|
||||
});
|
||||
|
||||
const client = new AgentClient({
|
||||
req,
|
||||
res,
|
||||
|
|
@ -930,6 +953,10 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
|
|||
/** Resolved endpoint token/pricing config so spending and cost reflect
|
||||
* configured rates for custom-endpoint agents instead of defaults. */
|
||||
endpointTokenConfig: primaryConfig.endpointTokenConfig,
|
||||
/** Per-agent override of the above for multi-endpoint graphs (connected
|
||||
* agents + subagents); falls back to the primary config when an agent
|
||||
* isn't present or has no configured rates. */
|
||||
endpointTokenConfigByAgentId,
|
||||
/** Capture sinks the handlers fill during the run; `sendCompletion` reads
|
||||
* them to persist the breakdown + usage rollup on the response message. */
|
||||
contextUsageSink,
|
||||
|
|
|
|||
|
|
@ -272,6 +272,11 @@ function buildSkillPrimedIdsByName(manualSkillPrimes, alwaysApplySkillPrimes) {
|
|||
function buildAgentToolContext({ agent, config }) {
|
||||
return {
|
||||
agent,
|
||||
/** Per-agent resolved endpoint token/pricing config. Retained here because
|
||||
* `agentToolContexts` is the one map that holds every agent — including
|
||||
* pure subagents pruned from `agentConfigs` — so usage can be priced with
|
||||
* the producing agent's config in multi-endpoint graphs. */
|
||||
endpointTokenConfig: config.endpointTokenConfig,
|
||||
toolRegistry: config.toolRegistry,
|
||||
mcpAvailableTools: config.mcpAvailableTools,
|
||||
requestScopedConnections: config.requestScopedConnections,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
aggregateEmittedUsage,
|
||||
createSubagentUsageSink,
|
||||
recordCollectedUsage,
|
||||
resolveAgentTokenConfig,
|
||||
buildPersistedContextUsage,
|
||||
buildAbortedResponseMetadata,
|
||||
} from './usage';
|
||||
|
|
@ -809,6 +810,77 @@ describe('recordCollectedUsage', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('prices each usage with its agent endpoint config (multi-endpoint graph)', async () => {
|
||||
/** Two endpoints share a model id but bill at different rates. */
|
||||
const primaryConfig = { 'shared-model': { prompt: 0.01, completion: 0.03, context: 8192 } };
|
||||
const subagentConfig = { 'shared-model': { prompt: 0.05, completion: 0.15, context: 8192 } };
|
||||
const byAgent: Record<string, typeof primaryConfig> = {
|
||||
primary: primaryConfig,
|
||||
sub: subagentConfig,
|
||||
};
|
||||
const collectedUsage: UsageMetadata[] = [
|
||||
{
|
||||
usage_type: 'message',
|
||||
agentId: 'primary',
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
model: 'shared-model',
|
||||
},
|
||||
{
|
||||
usage_type: 'subagent',
|
||||
agentId: 'sub',
|
||||
input_tokens: 200,
|
||||
output_tokens: 80,
|
||||
model: 'shared-model',
|
||||
},
|
||||
];
|
||||
|
||||
await recordCollectedUsage(deps, {
|
||||
...baseParams,
|
||||
collectedUsage,
|
||||
endpointTokenConfig: primaryConfig,
|
||||
resolveEndpointTokenConfig: (usage) =>
|
||||
usage.agentId != null ? byAgent[usage.agentId] : undefined,
|
||||
});
|
||||
|
||||
expect(mockSpendTokens).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: 'message',
|
||||
model: 'shared-model',
|
||||
endpointTokenConfig: primaryConfig,
|
||||
}),
|
||||
{ promptTokens: 100, completionTokens: 50 },
|
||||
);
|
||||
expect(mockSpendTokens).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
context: 'subagent',
|
||||
model: 'shared-model',
|
||||
endpointTokenConfig: subagentConfig,
|
||||
}),
|
||||
{ promptTokens: 200, completionTokens: 80 },
|
||||
);
|
||||
});
|
||||
|
||||
it('trusts the resolver result, including undefined (built-in pricing for known agents)', async () => {
|
||||
const batch = { 'gpt-4': { prompt: 0.01, completion: 0.03, context: 8192 } };
|
||||
await recordCollectedUsage(deps, {
|
||||
...baseParams,
|
||||
collectedUsage: [
|
||||
{ agentId: 'known-openai', input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
|
||||
],
|
||||
endpointTokenConfig: batch,
|
||||
/** A known agent with no configured rates: the resolver returns undefined
|
||||
* and the billing path must honor it (built-in pricing), NOT re-apply
|
||||
* the batch/primary config. */
|
||||
resolveEndpointTokenConfig: () => undefined,
|
||||
});
|
||||
|
||||
expect(mockSpendTokens).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ endpointTokenConfig: undefined }),
|
||||
{ promptTokens: 100, completionTokens: 50 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default context "message" when not provided', async () => {
|
||||
const collectedUsage: UsageMetadata[] = [
|
||||
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
|
||||
|
|
@ -1382,10 +1454,24 @@ describe('createSubagentUsageSink', () => {
|
|||
total_tokens: 1600,
|
||||
model: 'claude-haiku-4-5',
|
||||
provider: 'anthropic',
|
||||
agentId: 'researcher',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('tags the child agent id so the host can price with the subagent endpoint config', () => {
|
||||
const collectedUsage: UsageMetadata[] = [];
|
||||
const emitted: UsageMetadata[] = [];
|
||||
const sink = createSubagentUsageSink(collectedUsage, (u) => emitted.push(u));
|
||||
|
||||
sink(makeEvent({ subagentAgentId: 'agent_xyz' }));
|
||||
|
||||
expect(collectedUsage[0].agentId).toBe('agent_xyz');
|
||||
/** The same tagged object is handed to onUsage (the live emitter). */
|
||||
expect(emitted[0]).toBe(collectedUsage[0]);
|
||||
expect(emitted[0].agentId).toBe('agent_xyz');
|
||||
});
|
||||
|
||||
it('preserves cache token details from the child call', () => {
|
||||
const collectedUsage: UsageMetadata[] = [];
|
||||
const sink = createSubagentUsageSink(collectedUsage);
|
||||
|
|
@ -1698,3 +1784,48 @@ describe('buildAbortedResponseMetadata', () => {
|
|||
expect((result as { contextUsage?: unknown }).contextUsage).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAgentTokenConfig', () => {
|
||||
const primary = { 'gpt-4': { prompt: 0.01, completion: 0.03, context: 8192 } };
|
||||
const subagent = { 'gpt-4': { prompt: 0.05, completion: 0.15, context: 8192 } };
|
||||
|
||||
it('returns the producing agent’s own config', () => {
|
||||
const byAgentId = new Map([
|
||||
['primary', primary],
|
||||
['sub', subagent],
|
||||
]);
|
||||
expect(resolveAgentTokenConfig({ agentId: 'sub', byAgentId, fallback: primary })).toBe(
|
||||
subagent,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined for a known agent with no configured rates (built-in pricing)', () => {
|
||||
/** A known non-custom agent (e.g. a normal OpenAI agent) is recorded with an
|
||||
* undefined config; it must NOT inherit the custom-primary rates. */
|
||||
const byAgentId = new Map<string, typeof primary | undefined>([
|
||||
['primary', primary],
|
||||
['known-openai', undefined],
|
||||
]);
|
||||
expect(
|
||||
resolveAgentTokenConfig({ agentId: 'known-openai', byAgentId, fallback: primary }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to the primary config for an untagged usage', () => {
|
||||
const byAgentId = new Map([['primary', primary]]);
|
||||
expect(resolveAgentTokenConfig({ agentId: undefined, byAgentId, fallback: primary })).toBe(
|
||||
primary,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the primary config for an unknown agent id', () => {
|
||||
const byAgentId = new Map([['primary', primary]]);
|
||||
expect(resolveAgentTokenConfig({ agentId: 'ghost', byAgentId, fallback: primary })).toBe(
|
||||
primary,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the fallback when there is no per-agent map (single-endpoint graphs)', () => {
|
||||
expect(resolveAgentTokenConfig({ agentId: 'primary', fallback: primary })).toBe(primary);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -326,6 +326,31 @@ export function buildAbortedResponseMetadata(
|
|||
return usage ? { usage } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the endpoint token config for a usage item by its producing agent.
|
||||
* Multi-endpoint graphs tag each call with `agentId`; that agent's resolved
|
||||
* config is authoritative — including `undefined`, which means "no configured
|
||||
* rates, use built-in pricing" (e.g. a non-custom agent in a custom-primary
|
||||
* graph). Only an untagged or unknown agent falls back to `fallback` (the
|
||||
* primary config), so single-endpoint graphs are unchanged. `byAgentId` must
|
||||
* hold an entry for every known agent (value may be `undefined`) so `has`
|
||||
* distinguishes "known, no rates" from "unknown".
|
||||
*/
|
||||
export function resolveAgentTokenConfig({
|
||||
agentId,
|
||||
byAgentId,
|
||||
fallback,
|
||||
}: {
|
||||
agentId?: string | null;
|
||||
byAgentId?: Map<string, EndpointTokenConfig | undefined>;
|
||||
fallback?: EndpointTokenConfig;
|
||||
}): EndpointTokenConfig | undefined {
|
||||
if (agentId != null && byAgentId?.has(agentId)) {
|
||||
return byAgentId.get(agentId);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface RecordUsageParams {
|
||||
user: string;
|
||||
conversationId: string;
|
||||
|
|
@ -336,6 +361,15 @@ export interface RecordUsageParams {
|
|||
balance?: Partial<TCustomConfig['balance']> | null;
|
||||
transactions?: Partial<TTransactionsConfig>;
|
||||
endpointTokenConfig?: EndpointTokenConfig;
|
||||
/**
|
||||
* Per-usage endpoint token config resolver for multi-endpoint graphs. Called
|
||||
* with each usage item; when provided it is authoritative — its result prices
|
||||
* that item, including `undefined` (built-in pricing for a known agent with no
|
||||
* configured rates). It owns its own fallback to the primary config for
|
||||
* untagged/unknown agents (see {@link resolveAgentTokenConfig}). Single-config
|
||||
* callers (responses.js / openai.js) omit it and use `endpointTokenConfig`.
|
||||
*/
|
||||
resolveEndpointTokenConfig?: (usage: UsageMetadata) => EndpointTokenConfig | undefined;
|
||||
}
|
||||
|
||||
export interface RecordUsageResult {
|
||||
|
|
@ -363,6 +397,7 @@ export async function recordCollectedUsage(
|
|||
conversationId,
|
||||
collectedUsage,
|
||||
endpointTokenConfig,
|
||||
resolveEndpointTokenConfig,
|
||||
context = 'message',
|
||||
} = params;
|
||||
|
||||
|
|
@ -422,7 +457,12 @@ export async function recordCollectedUsage(
|
|||
messageId,
|
||||
transactions,
|
||||
conversationId,
|
||||
endpointTokenConfig,
|
||||
/** Price with the producing agent's endpoint config when a resolver is
|
||||
* provided (multi-endpoint graphs); it owns the fallback to the primary
|
||||
* config, so `undefined` here means built-in pricing, not the batch one. */
|
||||
endpointTokenConfig: resolveEndpointTokenConfig
|
||||
? resolveEndpointTokenConfig(usage)
|
||||
: endpointTokenConfig,
|
||||
context: usageContext,
|
||||
model: usage.model ?? model,
|
||||
};
|
||||
|
|
@ -562,6 +602,12 @@ export function createSubagentUsageSink(
|
|||
if (event.provider != null && event.provider !== '') {
|
||||
usage.provider = event.provider;
|
||||
}
|
||||
/** Tag the child's agent id so the host can price this usage with the
|
||||
* subagent's own endpoint token config (its endpoint may differ from the
|
||||
* parent's). The same tagged object is pushed AND handed to `onUsage`. */
|
||||
if (event.subagentAgentId != null && event.subagentAgentId !== '') {
|
||||
usage.agentId = event.subagentAgentId;
|
||||
}
|
||||
collectedUsage.push(usage);
|
||||
/** Lets the host stream the billed child usage to the client (tagged
|
||||
* `subagent`, so it folds into session cost/totals but not the live
|
||||
|
|
|
|||
|
|
@ -93,6 +93,9 @@ export interface UsageMetadata {
|
|||
model?: string;
|
||||
/** Provider identifier that generated this usage */
|
||||
provider?: string;
|
||||
/** Agent that produced this usage (graph agent id / subagent agent id). Lets
|
||||
* multi-endpoint graphs price each call with its own endpoint token config. */
|
||||
agentId?: string;
|
||||
/**
|
||||
* OpenAI-style cache token details.
|
||||
* Present for OpenAI models (GPT-4, o1, etc.)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue