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
112 changes: 112 additions & 0 deletions src/llm/openai/cacheWriteTokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { AIMessage } from '@langchain/core/messages';
import type { OpenAIClient } from '@langchain/openai';

import {
getCacheWriteTokens,
attachCacheWriteUsage,
attachCacheWriteMetadata,
} from './index';

/**
* Regression coverage for a crash reported against OpenAI-compatible
* third-party servers (e.g. mlx_vlm.server) whose `/v1/responses` usage
* payload omits `input_tokens_details` entirely — a shape the OpenAI API
* itself always populates, but which `ResponsesUsageWithCacheWrite`
* declares optional. Reading `.cache_write_tokens` off that missing object
* without a second `?.` threw "Cannot read properties of undefined
* (reading 'cache_write_tokens')" on every completion from such a server.
*/
describe('cache write token extraction (Responses API)', () => {
describe('getCacheWriteTokens', () => {
it('returns undefined without throwing when usage has no input_tokens_details', () => {
const message = new AIMessage({
content: 'hi',
response_metadata: {
usage: {
input_tokens: 10,
output_tokens: 2,
total_tokens: 12,
// input_tokens_details intentionally omitted, mirroring a
// minimal OpenAI-compatible server's usage payload.
},
},
});

expect(() => getCacheWriteTokens(message)).not.toThrow();
expect(getCacheWriteTokens(message)).toBeUndefined();
});

it('returns undefined without throwing when there is no usage at all', () => {
const message = new AIMessage({
content: 'hi',
response_metadata: {},
});

expect(() => getCacheWriteTokens(message)).not.toThrow();
expect(getCacheWriteTokens(message)).toBeUndefined();
});

it('still reports cache_write_tokens when the field is present', () => {
const message = new AIMessage({
content: 'hi',
response_metadata: {
usage: {
input_tokens: 10,
output_tokens: 2,
total_tokens: 12,
input_tokens_details: { cache_write_tokens: 5 },
},
},
});

expect(getCacheWriteTokens(message)).toBe(5);
});

it('falls back to the serialized metadata key when usage is absent', () => {
const message = new AIMessage({
content: 'hi',
response_metadata: {
metadata: { __librechat_cache_write_tokens: '7' },
},
});

expect(getCacheWriteTokens(message)).toBe(7);
});
});

describe('attachCacheWriteUsage', () => {
it('leaves usage_metadata untouched without throwing when input_tokens_details is missing', () => {
const message = new AIMessage({
content: 'hi',
response_metadata: {
usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 },
},
usage_metadata: {
input_tokens: 10,
output_tokens: 2,
total_tokens: 12,
},
});

expect(() => attachCacheWriteUsage(message)).not.toThrow();
expect(message.usage_metadata?.input_token_details).toBeUndefined();
});
});

describe('attachCacheWriteMetadata', () => {
it('returns the response unmodified without throwing when input_tokens_details is missing', () => {
const response = {
usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 },
} as OpenAIClient.Responses.Response;

expect(() => attachCacheWriteMetadata(response)).not.toThrow();
expect(attachCacheWriteMetadata(response)).toBe(response);
});

it('returns the response unmodified without throwing when usage is missing entirely', () => {
const response = {} as OpenAIClient.Responses.Response;

expect(() => attachCacheWriteMetadata(response)).not.toThrow();
});
});
});
20 changes: 14 additions & 6 deletions src/llm/openai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,15 @@ type CacheableResponsePart = (
) & {
prompt_cache_breakpoint?: { mode: 'explicit' };
};
type ResponsesUsageWithCacheWrite = OpenAIClient.Responses.ResponseUsage & {
// `Omit` before re-adding `input_tokens_details` as optional matters: the SDK's own
// `ResponseUsage` declares it required (true for OpenAI itself), so a plain intersection
// would keep it required in the merged type despite the `?:` here — masking, at the type
// level, that OpenAI-*compatible* servers (e.g. mlx_vlm.server) may omit it entirely.
// Mirrors `CompletionUsageWithCacheWrite`'s handling of the analogous Completions API field.
type ResponsesUsageWithCacheWrite = Omit<
OpenAIClient.Responses.ResponseUsage,
'input_tokens_details'
> & {
input_tokens_details?: OpenAIClient.Responses.ResponseUsage['input_tokens_details'] & {
cache_write_tokens?: number;
};
Expand Down Expand Up @@ -509,13 +517,13 @@ export function shouldIncludeEncryptedReasoning(
);
}

function getCacheWriteTokens(message: BaseMessage): number | undefined {
export function getCacheWriteTokens(message: BaseMessage): number | undefined {
const responseMetadata = message.response_metadata as {
usage?: ResponsesUsageWithCacheWrite;
metadata?: Record<string, string>;
};
const reported =
responseMetadata.usage?.input_tokens_details.cache_write_tokens;
responseMetadata.usage?.input_tokens_details?.cache_write_tokens;
if (reported != null) {
return reported;
}
Expand All @@ -527,7 +535,7 @@ function getCacheWriteTokens(message: BaseMessage): number | undefined {
return Number.isFinite(parsed) ? parsed : undefined;
}

function attachCacheWriteUsage(message: BaseMessage): void {
export function attachCacheWriteUsage(message: BaseMessage): void {
const cacheWriteTokens = getCacheWriteTokens(message);
if (
cacheWriteTokens == null ||
Expand All @@ -554,11 +562,11 @@ function attachCacheWriteUsage(message: BaseMessage): void {
};
}

function attachCacheWriteMetadata(
export function attachCacheWriteMetadata(
response: OpenAIClient.Responses.Response
): OpenAIClient.Responses.Response {
const usage = response.usage as ResponsesUsageWithCacheWrite | undefined;
const cacheWriteTokens = usage?.input_tokens_details.cache_write_tokens;
const cacheWriteTokens = usage?.input_tokens_details?.cache_write_tokens;
if (cacheWriteTokens == null) {
return response;
}
Expand Down
2 changes: 1 addition & 1 deletion src/tools/CodeExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const emptyOutputMessage =
'stdout: Empty. Ensure you\'re writing output explicitly.\n';

export const CODE_ARTIFACT_PATH_GUIDANCE =
'Persist handoff artifacts in `/mnt/data` with standard extensions (.json/.txt/.csv/.tsv/.log/.parquet/.png/.jpg/.pdf/.xlsx); failed executions do not register new files; `/tmp` and odd extensions are same-call scratch only, not later-call storage.';
'Anything a later call needs (data, helper scripts/modules, partial results) MUST be written under `/mnt/data` in the same call that produces it; `/tmp` never survives the call. `/mnt/data` keeps files with recognized extensions, covering common source, text, data, document, image, and archive formats (.py/.sh/.sql/.md/.json/.csv/.parquet/.png/.pdf/.zip and similar); extensionless or unusual extensions are not kept. Failed executions register nothing; fix the error and rerun before relying on new files.';

export const BASH_SHELL_GUIDANCE =
'Bash: multi-line files use heredoc/printf; run Python via python3 -c/heredoc, not bare Python.';
Expand Down
4 changes: 2 additions & 2 deletions src/tools/__tests__/BashExecutor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ describe('buildBashExecutionToolDescription', () => {
expect(BashExecutionToolDescription).toContain('heredoc/printf');
expect(BashExecutionToolDescription).toContain('not bare Python');
expect(BashExecutionToolDescription).toContain(
'failed executions do not register new files'
'Failed executions register nothing'
);
expect(BashExecutionToolDescription).toContain('not later-call storage');
expect(BashExecutionToolDescription).toContain('`/tmp` never survives the call');
});

it('appends the tool-output references guide when enabled', () => {
Expand Down
6 changes: 2 additions & 4 deletions src/tools/__tests__/ProgrammaticToolCalling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,8 @@ describe('ProgrammaticToolCalling', () => {
expect(description).toContain('raw=$(tool');
expect(description).toContain('direct tool > file may be empty');
expect(description).toContain('/mnt/data/sf.json');
expect(description).toContain(
'failed executions do not register new files'
);
expect(description).toContain('not later-call storage');
expect(description).toContain('Failed executions register nothing');
expect(description).toContain('`/tmp` never survives the call');
});
});

Expand Down
Loading