Files
agents/eslint.config.mjs
Danny AvilaandGitHub 5f880fc6ac fix: dedupe repeated scalar response_metadata in streamed chunks (#295)
* fix: dedupe repeated scalar response_metadata in streamed chunks

Providers that emit `finish_reason` on more than one streamed chunk (e.g.
OpenRouter / custom OpenAI-compatible endpoints) made LangChain core's
`_mergeDicts` concatenate the scalar fields `@langchain/openai` stamps
alongside finish_reason, producing `finish_reason: "stopstop"` and
duplicated `model_name` in both the aggregated graph message and the
Langfuse trace (core keeps `id`/`name`/`model_provider` last, but not these).

Fold a keep-first dedupe into the shared `delayStreamChunks` wrapper so
repeated `model_name`/`finish_reason`/`service_tier`/`system_fingerprint`
are dropped at the chunk source, before either the core `_streamIterator`
aggregation (handleLLMEnd / Langfuse) or any host-side concat runs. Single
occurrences are untouched. Covers ChatOpenAI/Azure/DeepSeek/XAI, and
ChatOpenRouter/ChatMoonshot via inheritance.

* refactor: move stream metadata dedupe helper to sibling module

* fix: dedupe stream metadata before emitting token callbacks

Move dropRepeatedScalarMetadata ahead of emitStreamChunkCallback so token
callbacks observe the same cleaned chunk as the yielded/aggregated one.
Keep-first means the first finish chunk retains its metadata; prompt/completion
token indices are untouched. Adds a token-callback assertion to the regression.

* fix: scope stream metadata dedupe by completion, clone, and preserve subclass finish signal

Addresses Codex P2/P3 findings on the scalar-metadata de-dup:
- Key `seen` by completion index so multi-choice (n>1) responses, which
  `_generate` aggregates per choice, keep each choice's finish metadata.
- Clone generationInfo/response_metadata before deleting, so providers that
  share those objects across split chunks (ChatDeepSeek) don't lose metadata
  from an already-emitted sibling.
- De-duplicate at each terminal emit rather than in the shared super path:
  delayStreamChunks dedupes only when given a seen-map; ChatOpenAI exposes a
  raw variant, and ChatOpenRouter consumes it so its finish_reason-driven
  reasoning flush runs before de-dup. Verified live on openai/gpt-chat-latest
  and deepseek/deepseek-r1 (reasoning details preserved, metadata de-duped).
2026-07-09 10:40:22 -04:00

126 lines
3.7 KiB
JavaScript

import { defineConfig, globalIgnores } from "eslint/config";
import { fixupConfigRules, fixupPluginRules } from "@eslint/compat";
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import _import from "eslint-plugin-import";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
export default defineConfig([globalIgnores([
"dist/**/*",
"config/**/*",
"routes/**/*",
"**/*.js",
"**/*.mjs",
"src/proto/",
"src/scripts/",
"types/**/*",
"./script_docs.ts",
"src/llm/anthropic/llm.spec.ts",
"src/llm/anthropic/inherited-strict.spec.ts",
"src/llm/anthropic/inherited-stream-events.spec.ts",
"src/llm/anthropic/inherited-content-utils.spec.ts",
"src/llm/google/llm.spec.ts",
"src/llm/google/inherited-stream-events.spec.ts",
"src/llm/openai/llm.spec.ts",
"src/llm/openai/inherited-deepseek.spec.ts",
"src/llm/openai/inherited-xai.spec.ts",
"src/llm/openai/streamMetadataDedup.spec.ts",
"src/llm/openai/streamMetadata.spec.ts",
"src/llm/bedrock/llm.spec.ts",
"src/llm/bedrock/inherited-cache.spec.ts",
"src/llm/bedrock/inherited.spec.ts",
"src/llm/vertexai/llm.spec.ts",
"src/llm/vertexai/inherited-stream-events.spec.ts",
]), {
extends: fixupConfigRules(compat.extends(
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/errors",
"plugin:import/warnings",
"plugin:import/typescript",
)),
plugins: {
"@typescript-eslint": fixupPluginRules(typescriptEslint),
import: fixupPluginRules(_import),
},
languageOptions: {
globals: {
...globals.node,
},
parser: tsParser,
ecmaVersion: 2021,
sourceType: "module",
parserOptions: {
project: ["./tsconfig.json", "./scripts/tsconfig.json"],
},
},
settings: {
"import/resolver": {
typescript: {
alwaysTryTypes: true,
project: "./tsconfig.json",
},
},
},
rules: {
"no-trailing-spaces": "error",
indent: ["error", 2],
"linebreak-style": ["error", "unix"],
quotes: ["error", "single"],
semi: ["error", "always"],
"no-multiple-empty-lines": ["error", {
max: 1,
maxEOF: 0,
}],
"no-console": "warn",
"prefer-const": "error",
"@typescript-eslint/no-unused-vars": ["error", {
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_"
}],
"@typescript-eslint/consistent-type-assertions": "error",
"@typescript-eslint/explicit-function-return-type": "error",
"@typescript-eslint/no-explicit-any": "error",
"no-nested-ternary": "error",
"@typescript-eslint/no-unnecessary-condition": "warn",
"@typescript-eslint/strict-boolean-expressions": "warn",
},
}, {
files: ["src/stream.ts", "src/utils/logging.ts", "scripts/**/*.ts"],
rules: {
"no-console": "off",
},
}, {
files: ["**/*.test.ts", "**/*.spec.ts", "**/__tests__/**/*.ts"],
rules: {
"@typescript-eslint/no-require-imports": "off",
"@typescript-eslint/explicit-function-return-type": "off",
},
}]);