🐛 fix(bedrock): add parameter conflict handling for Claude 4+ models (#9627)

* fix(bedrock): add parameter conflict handling for Claude 4+ models

- Add logic to prevent sending both temperature and top_p for Claude 4+ models
- Matches existing implementation in Anthropic provider
- Fixes ValidationException error for Claude 4.5 models via Bedrock
- Includes support for both standard and Bedrock-specific model IDs

Fixes #9523

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Arvin Xu <arvinxx@users.noreply.github.com>

* 🧪 test(bedrock): add parameter conflict handling tests

Add comprehensive tests for Claude 4+ models parameter conflict detection:
- Test temperature preference over top_p when both provided
- Test top_p usage when temperature not provided
- Test both parameters allowed for non-Claude-4+ models
- Test standard and Bedrock-specific model ID formats
- Test US region model IDs

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Arvin Xu <arvinxx@users.noreply.github.com>

* 🔧 fix: make temperature optional in ChatStreamPayload interface

The temperature property should be optional to support test cases and scenarios where only top_p is provided. This resolves TypeScript error TS2741 in Bedrock provider tests.

Co-authored-by: Arvin Xu <arvinxx@users.noreply.github.com>

* 🔧 fix: correct test assertions to handle JSON.stringify undefined omission

- Remove undefined properties from test expectations since JSON.stringify omits them
- Fix temperature/top_p conflict test assertions for Claude 4+ models
- Ensure tests match actual JSON serialization behavior

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Arvin Xu <arvinxx@users.noreply.github.com>

* 🔧 fix: add null safety checks for optional temperature parameter

- Added proper undefined checks before temperature arithmetic operations in anthropic and bedrock providers
- Added null checks before temperature comparisons in groq, perplexity, and search1api providers
- Resolves TS18048 errors where temperature is possibly undefined
- Maintains existing logic while satisfying TypeScript strict null checks

Co-authored-by: Arvin Xu <arvinxx@users.noreply.github.com>

* refactor with parameterResolver

* upgrade

* upgrade swr

* refactor context-builder

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Arvin Xu <arvinxx@users.noreply.github.com>
This commit is contained in:
Arvin Xu
2025-10-11 09:08:26 +02:00
committed by GitHub
parent 020ef51141
commit 54b6217256
38 changed files with 1011 additions and 227 deletions
+60 -60
View File
@@ -129,20 +129,20 @@
"@azure-rest/ai-inference": "1.0.0-beta.5",
"@azure/core-auth": "^1.10.1",
"@cfworker/json-schema": "^4.1.1",
"@clerk/localizations": "^3.25.0",
"@clerk/nextjs": "^6.31.10",
"@clerk/themes": "^2.4.18",
"@clerk/localizations": "^3.25.7",
"@clerk/nextjs": "^6.33.3",
"@clerk/themes": "^2.4.25",
"@codesandbox/sandpack-react": "^2.20.0",
"@cyntler/react-doc-viewer": "^1.17.0",
"@cyntler/react-doc-viewer": "^1.17.1",
"@electric-sql/pglite": "0.2.17",
"@emotion/react": "^11.14.0",
"@fal-ai/client": "^1.6.2",
"@formkit/auto-animate": "^0.9.0",
"@google/genai": "^1.19.0",
"@google/genai": "^1.24.0",
"@huggingface/inference": "^2.8.1",
"@icons-pack/react-simple-icons": "^13.8.0",
"@khmyznikov/pwa-install": "0.3.9",
"@langchain/community": "^0.3.55",
"@langchain/community": "^0.3.57",
"@lobechat/agent-runtime": "workspace:*",
"@lobechat/const": "workspace:*",
"@lobechat/context-engine": "workspace:*",
@@ -160,48 +160,48 @@
"@lobehub/charts": "^2.1.2",
"@lobehub/chat-plugin-sdk": "^1.32.4",
"@lobehub/chat-plugins-gateway": "^1.9.0",
"@lobehub/editor": "^1.11.0",
"@lobehub/icons": "^2.32.2",
"@lobehub/editor": "^1.16.0",
"@lobehub/icons": "^2.42.0",
"@lobehub/market-sdk": "^0.22.7",
"@lobehub/tts": "^2.0.1",
"@lobehub/ui": "^2.13.0",
"@modelcontextprotocol/sdk": "^1.18.0",
"@neondatabase/serverless": "^1.0.1",
"@next/third-parties": "^15.5.3",
"@lobehub/ui": "^2.13.2",
"@modelcontextprotocol/sdk": "^1.20.0",
"@neondatabase/serverless": "^1.0.2",
"@next/third-parties": "^15.5.4",
"@react-spring/web": "^9.7.5",
"@serwist/next": "^9.2.1",
"@t3-oss/env-nextjs": "^0.13.8",
"@tanstack/react-query": "^5.87.4",
"@trpc/client": "^11.5.1",
"@trpc/next": "^11.5.1",
"@trpc/react-query": "^11.5.1",
"@trpc/server": "^11.5.1",
"@tanstack/react-query": "^5.90.2",
"@trpc/client": "^11.6.0",
"@trpc/next": "^11.6.0",
"@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0",
"@vercel/analytics": "^1.5.0",
"@vercel/edge-config": "^1.4.0",
"@vercel/functions": "^3.0.0",
"@vercel/functions": "^3.1.3",
"@vercel/speed-insights": "^1.2.0",
"@xterm/xterm": "^5.5.0",
"ahooks": "^3.9.5",
"antd": "^5.27.3",
"antd": "^5.27.4",
"antd-style": "^3.7.1",
"brotli-wasm": "^3.0.1",
"chroma-js": "^3.1.2",
"cookie": "^1.0.2",
"countries-and-timezones": "^3.8.0",
"dayjs": "^1.11.18",
"debug": "^4.4.1",
"debug": "^4.4.3",
"dexie": "^3.2.7",
"diff": "^8.0.2",
"drizzle-orm": "^0.44.5",
"drizzle-orm": "^0.44.6",
"drizzle-zod": "^0.5.1",
"epub2": "^3.0.2",
"fast-deep-equal": "^3.1.3",
"file-type": "^21.0.0",
"framer-motion": "^12.23.12",
"gpt-tokenizer": "^3.0.1",
"framer-motion": "^12.23.24",
"gpt-tokenizer": "^3.2.0",
"gray-matter": "^4.0.3",
"html-to-text": "^9.0.5",
"i18next": "^25.5.2",
"i18next": "^25.6.0",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-resources-to-backend": "^1.2.1",
"idb-keyval": "^6.2.2",
@@ -210,24 +210,24 @@
"js-sha256": "^0.11.1",
"jsonl-parse-stringify": "^1.0.3",
"keyv": "^4.5.4",
"langchain": "^0.3.33",
"langchain": "^0.3.35",
"langfuse": "^3.38.5",
"langfuse-core": "^3.38.5",
"lodash-es": "^4.17.21",
"lucide-react": "^0.544.0",
"mammoth": "^1.10.0",
"mammoth": "^1.11.0",
"markdown-to-txt": "^2.0.1",
"mdast-util-to-markdown": "^2.1.2",
"model-bank": "workspace:*",
"modern-screenshot": "^4.6.6",
"nanoid": "^5.1.5",
"nanoid": "^5.1.6",
"next": "~15.3.5",
"next-auth": "5.0.0-beta.29",
"next-mdx-remote": "^5.0.0",
"nextjs-toploader": "^3.9.17",
"node-machine-id": "^1.1.12",
"numeral": "^2.0.6",
"nuqs": "^2.6.0",
"nuqs": "^2.7.1",
"officeparser": "5.1.1",
"oidc-provider": "^9.5.1",
"ollama": "^0.6.0",
@@ -238,43 +238,43 @@
"pdf-parse": "^1.1.1",
"pdfjs-dist": "4.8.69",
"pg": "^8.16.3",
"pino": "^9.9.5",
"pino": "^9.13.1",
"plaiceholder": "^3.0.0",
"polished": "^4.3.1",
"posthog-js": "^1.264.2",
"posthog-js": "^1.275.1",
"pure-rand": "^7.0.1",
"pwa-install-handler": "^2.6.3",
"query-string": "^9.3.0",
"query-string": "^9.3.1",
"random-words": "^2.0.1",
"react": "^19.1.1",
"react": "^19.2.0",
"react-confetti": "^6.4.0",
"react-dom": "^19.1.1",
"react-dom": "^19.2.0",
"react-fast-marquee": "^1.6.5",
"react-hotkeys-hook": "^5.1.0",
"react-i18next": "^15.7.3",
"react-i18next": "^15.7.4",
"react-layout-kit": "^2.0.0",
"react-lazy-load": "^4.0.1",
"react-pdf": "^9.2.1",
"react-rnd": "^10.5.2",
"react-scan": "^0.4.3",
"react-virtuoso": "^4.14.0",
"react-virtuoso": "^4.14.1",
"react-wrap-balancer": "^1.1.1",
"remark": "^15.0.1",
"remark-gfm": "^4.0.1",
"remark-html": "^16.0.1",
"resolve-accept-language": "^3.1.13",
"rtl-detect": "^1.1.2",
"semver": "^7.7.2",
"sharp": "^0.34.3",
"shiki": "^3.12.2",
"semver": "^7.7.3",
"sharp": "^0.34.4",
"shiki": "^3.13.0",
"ssrf-safe-fetch": "workspace:*",
"stripe": "^17.7.0",
"superjson": "^2.2.2",
"svix": "^1.76.1",
"swr": "2.3.4",
"svix": "^1.77.0",
"swr": "^2.3.6",
"systemjs": "^6.15.1",
"tokenx": "^1.0.0",
"ts-md5": "^2.0.0",
"tokenx": "^1.1.0",
"ts-md5": "^2.0.1",
"ua-parser-js": "^1.0.41",
"unstructured-client": "^0.19.0",
"url-join": "^5.0.0",
@@ -290,14 +290,14 @@
"devDependencies": {
"@commitlint/cli": "^19.8.1",
"@edge-runtime/vm": "^5.0.0",
"@huggingface/tasks": "^0.19.0",
"@huggingface/tasks": "^0.19.50",
"@lobechat/types": "workspace:*",
"@lobehub/i18n-cli": "^1.25.1",
"@lobehub/lint": "^1.26.2",
"@lobehub/market-types": "^1.11.4",
"@lobehub/seo-cli": "^1.7.0",
"@next/bundle-analyzer": "^15.5.3",
"@next/eslint-plugin-next": "^15.5.3",
"@next/bundle-analyzer": "^15.5.4",
"@next/eslint-plugin-next": "^15.5.4",
"@peculiar/webcrypto": "^1.5.0",
"@prettier/sync": "^0.6.1",
"@semantic-release/exec": "^6.0.3",
@@ -312,12 +312,12 @@
"@types/json-schema": "^7.0.15",
"@types/lodash": "^4.17.20",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.18.1",
"@types/node": "^22.18.9",
"@types/numeral": "^2.0.5",
"@types/oidc-provider": "^9.5.0",
"@types/pg": "^8.15.5",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.1",
"@types/rtl-detect": "^1.0.3",
"@types/semver": "^7.7.1",
"@types/systemjs": "^6.15.3",
@@ -329,28 +329,28 @@
"ajv-keywords": "^5.1.0",
"commitlint": "^19.8.1",
"consola": "^3.4.2",
"cross-env": "^10.0.0",
"cross-env": "^10.1.0",
"crypto-js": "^4.2.0",
"dbdocs": "^0.16.1",
"dotenv": "^17.2.2",
"dbdocs": "^0.16.2",
"dotenv": "^17.2.3",
"dotenv-expand": "^12.0.3",
"dpdm-fast": "^1.0.13",
"dpdm-fast": "^1.0.14",
"drizzle-dbml-generator": "^0.10.0",
"drizzle-kit": "^0.31.4",
"drizzle-kit": "^0.31.5",
"eslint": "^8.57.1",
"eslint-plugin-mdx": "^3.6.2",
"fake-indexeddb": "^6.2.2",
"fs-extra": "^11.3.1",
"fake-indexeddb": "^6.2.3",
"fs-extra": "^11.3.2",
"glob": "^11.0.3",
"happy-dom": "^18.0.1",
"husky": "^9.1.7",
"import-in-the-middle": "^1.14.2",
"import-in-the-middle": "^1.15.0",
"just-diff": "^6.0.2",
"lint-staged": "^15.5.2",
"lodash": "^4.17.21",
"markdown-table": "^3.0.4",
"mcp-hello-world": "^1.1.2",
"mime": "^4.0.7",
"mime": "^4.1.0",
"node-fetch": "^3.3.2",
"node-gyp": "^11.4.2",
"openapi-typescript": "^7.9.1",
@@ -364,12 +364,12 @@
"semantic-release": "^21.1.2",
"serwist": "^9.2.1",
"stylelint": "^15.11.0",
"tsx": "^4.20.5",
"tsx": "^4.20.6",
"type-fest": "^4.41.0",
"typescript": "^5.9.2",
"typescript": "^5.9.3",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0",
"vite": "^7.1.5",
"vite": "^7.1.9",
"vitest": "^3.2.4"
},
"packageManager": "pnpm@10.18.0",
@@ -1,25 +1,25 @@
import { OpenAI } from 'openai';
import { describe, expect, it, vi } from 'vitest';
import { OpenAIChatMessage, UserMessageContentPart } from '../types/chat';
import { imageUrlToBase64 } from '../utils/imageToBase64';
import { OpenAIChatMessage, UserMessageContentPart } from '../../types/chat';
import { imageUrlToBase64 } from '../../utils/imageToBase64';
import { parseDataUri } from '../../utils/uriParser';
import {
buildAnthropicBlock,
buildAnthropicMessage,
buildAnthropicMessages,
buildAnthropicTools,
} from './anthropicHelpers';
import { parseDataUri } from './uriParser';
} from './anthropic';
// Mock the parseDataUri function since it's an implementation detail
vi.mock('./uriParser', () => ({
vi.mock('../../utils/uriParser', () => ({
parseDataUri: vi.fn().mockReturnValue({
mimeType: 'image/jpeg',
base64: 'base64EncodedString',
type: 'base64',
}),
}));
vi.mock('../utils/imageToBase64');
vi.mock('../../utils/imageToBase64');
describe('anthropicHelpers', () => {
describe('buildAnthropicBlock', () => {
@@ -1,9 +1,9 @@
import Anthropic from '@anthropic-ai/sdk';
import OpenAI from 'openai';
import { OpenAIChatMessage, UserMessageContentPart } from '../types';
import { imageUrlToBase64 } from '../utils/imageToBase64';
import { parseDataUri } from './uriParser';
import { OpenAIChatMessage, UserMessageContentPart } from '../../types';
import { imageUrlToBase64 } from '../../utils/imageToBase64';
import { parseDataUri } from '../../utils/uriParser';
export const buildAnthropicBlock = async (
content: UserMessageContentPart,
@@ -1,18 +1,18 @@
import OpenAI from 'openai';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { imageUrlToBase64 } from './imageToBase64';
import { imageUrlToBase64 } from '../../utils/imageToBase64';
import { parseDataUri } from '../../utils/uriParser';
import {
convertImageUrlToFile,
convertMessageContent,
convertOpenAIMessages,
convertOpenAIResponseInputs,
} from './openaiHelpers';
import { parseDataUri } from './uriParser';
} from './openai';
// 模拟依赖
vi.mock('./imageToBase64');
vi.mock('./uriParser');
vi.mock('../../utils/imageToBase64');
vi.mock('../../utils/uriParser');
describe('convertMessageContent', () => {
beforeEach(() => {
@@ -1,9 +1,9 @@
import OpenAI, { toFile } from 'openai';
import { disableStreamModels, systemToUserModels } from '../const/models';
import { ChatStreamPayload, OpenAIChatMessage } from '../types';
import { imageUrlToBase64 } from './imageToBase64';
import { parseDataUri } from './uriParser';
import { disableStreamModels, systemToUserModels } from '../../const/models';
import { ChatStreamPayload, OpenAIChatMessage } from '../../types';
import { imageUrlToBase64 } from '../../utils/imageToBase64';
import { parseDataUri } from '../../utils/uriParser';
export const convertMessageContent = async (
content: OpenAI.ChatCompletionContentPart,
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { convertSenseNovaMessage } from './sensenovaHelpers';
import { convertSenseNovaMessage } from './sensenova';
describe('convertSenseNovaMessage', () => {
it('should convert string content to text type array', () => {
@@ -6,8 +6,8 @@ import OpenAI from 'openai';
import { CreateImagePayload, CreateImageResponse } from '../../types/image';
import { getModelPricing } from '../../utils/getModelPricing';
import { imageUrlToBase64 } from '../../utils/imageToBase64';
import { convertImageUrlToFile } from '../../utils/openaiHelpers';
import { parseDataUri } from '../../utils/uriParser';
import { convertImageUrlToFile } from '../contextBuilders/openai';
import { convertOpenAIImageUsage } from '../usageConverters/openai';
const log = createDebug('lobe-image:openai-compatible');
@@ -11,7 +11,7 @@ import type { Stream } from 'openai/streaming';
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as debugStreamModule from '../../utils/debugStream';
import * as openaiHelpers from '../../utils/openaiHelpers';
import * as openaiHelpers from '../contextBuilders/openai';
import { createOpenAICompatibleRuntime } from './index';
const sleep = async (ms: number) =>
@@ -29,10 +29,10 @@ import { desensitizeUrl } from '../../utils/desensitizeUrl';
import { getModelPropertyWithFallback } from '../../utils/getFallbackModelProperty';
import { getModelPricing } from '../../utils/getModelPricing';
import { handleOpenAIError } from '../../utils/handleOpenAIError';
import { convertOpenAIMessages, convertOpenAIResponseInputs } from '../../utils/openaiHelpers';
import { postProcessModelList } from '../../utils/postProcessModelList';
import { StreamingResponse } from '../../utils/response';
import { LobeRuntimeAI } from '../BaseAI';
import { convertOpenAIMessages, convertOpenAIResponseInputs } from '../contextBuilders/openai';
import { OpenAIResponsesStream, OpenAIStream, OpenAIStreamOptions } from '../streams';
import { createOpenAICompatibleImage } from './createImage';
import { transformResponseAPIToStream, transformResponseToStream } from './nonStreamToStream';
@@ -0,0 +1,300 @@
import { describe, expect, it } from 'vitest';
import {
MODEL_PARAMETER_CONFLICTS,
createParameterResolver,
resolveParameters,
} from './parameterResolver';
describe('resolveParameters', () => {
describe('Basic functionality', () => {
it('should return empty object when no parameters are provided', () => {
const result = resolveParameters({}, {});
expect(result).toEqual({});
});
it('should normalize temperature by dividing by 2 by default', () => {
const result = resolveParameters({ temperature: 1 }, {});
expect(result).toEqual({ temperature: 0.5 });
});
it('should not normalize temperature when normalizeTemperature is false', () => {
const result = resolveParameters({ temperature: 1 }, { normalizeTemperature: false });
expect(result).toEqual({ temperature: 1 });
});
it('should pass through top_p unchanged', () => {
const result = resolveParameters({ top_p: 0.9 }, {});
expect(result).toEqual({ top_p: 0.9 });
});
it('should return both parameters when no conflict', () => {
const result = resolveParameters({ temperature: 1, top_p: 0.9 }, { hasConflict: false });
expect(result).toEqual({ temperature: 0.5, top_p: 0.9 });
});
});
describe('Conflict handling', () => {
it('should prefer temperature over top_p when hasConflict is true', () => {
const result = resolveParameters(
{ temperature: 1, top_p: 0.9 },
{ hasConflict: true, preferTemperature: true },
);
expect(result).toEqual({ temperature: 0.5 });
});
it('should prefer top_p over temperature when preferTemperature is false', () => {
const result = resolveParameters(
{ temperature: 1, top_p: 0.9 },
{ hasConflict: true, preferTemperature: false },
);
expect(result).toEqual({ top_p: 0.9 });
});
it('should return temperature when only temperature is provided with conflict', () => {
const result = resolveParameters({ temperature: 1 }, { hasConflict: true });
expect(result).toEqual({ temperature: 0.5 });
});
it('should return top_p when only top_p is provided with conflict', () => {
const result = resolveParameters({ top_p: 0.9 }, { hasConflict: true });
expect(result).toEqual({ top_p: 0.9 });
});
});
describe('Range constraints', () => {
it('should apply temperature min constraint', () => {
const result = resolveParameters(
{ temperature: 0.02 }, // 0.02 / 2 = 0.01
{ temperatureRange: { min: 0.05 } },
);
expect(result).toEqual({ temperature: 0.05 });
});
it('should apply temperature max constraint', () => {
const result = resolveParameters(
{ temperature: 2 }, // 2 / 2 = 1
{ temperatureRange: { max: 0.99 } },
);
expect(result).toEqual({ temperature: 0.99 });
});
it('should apply top_p min constraint', () => {
const result = resolveParameters({ top_p: 0.005 }, { topPRange: { min: 0.01 } });
expect(result).toEqual({ top_p: 0.01 });
});
it('should apply top_p max constraint', () => {
const result = resolveParameters({ top_p: 1.5 }, { topPRange: { max: 0.99 } });
expect(result).toEqual({ top_p: 0.99 });
});
it('should apply both min and max constraints', () => {
const result = resolveParameters(
{ temperature: 0.02, top_p: 0.005 },
{
temperatureRange: { max: 0.99, min: 0.01 },
topPRange: { max: 0.99, min: 0.01 },
},
);
expect(result).toEqual({ temperature: 0.01, top_p: 0.01 });
});
});
describe('Additional parameters', () => {
it('should handle frequency_penalty', () => {
const result = resolveParameters({ frequency_penalty: 0.5 }, {});
expect(result).toEqual({ frequency_penalty: 0.5 });
});
it('should handle presence_penalty', () => {
const result = resolveParameters({ presence_penalty: 0.5 }, {});
expect(result).toEqual({ presence_penalty: 0.5 });
});
it('should handle max_tokens', () => {
const result = resolveParameters({ max_tokens: 1000 }, {});
expect(result).toEqual({ max_tokens: 1000 });
});
it('should apply frequency_penalty range constraints', () => {
const result = resolveParameters(
{ frequency_penalty: 3 },
{ frequencyPenaltyRange: { max: 2, min: -2 } },
);
expect(result).toEqual({ frequency_penalty: 2 });
});
it('should apply presence_penalty range constraints', () => {
const result = resolveParameters(
{ presence_penalty: -3 },
{ presencePenaltyRange: { max: 2, min: -2 } },
);
expect(result).toEqual({ presence_penalty: -2 });
});
it('should apply max_tokens range constraints', () => {
const result = resolveParameters({ max_tokens: 100_000 }, { maxTokensRange: { max: 8192 } });
expect(result).toEqual({ max_tokens: 8192 });
});
it('should handle all parameters together', () => {
const result = resolveParameters(
{
frequency_penalty: 0.5,
max_tokens: 2000,
presence_penalty: 0.5,
temperature: 1,
top_p: 0.9,
},
{},
);
expect(result).toEqual({
frequency_penalty: 0.5,
max_tokens: 2000,
presence_penalty: 0.5,
temperature: 0.5,
top_p: 0.9,
});
});
});
describe('Real-world scenarios', () => {
it('should handle Claude Opus 4.1 scenario (conflict with normalization)', () => {
const result = resolveParameters(
{ temperature: 1, top_p: 0.9 },
{ hasConflict: true, normalizeTemperature: true, preferTemperature: true },
);
expect(result).toEqual({ temperature: 0.5 });
});
it('should handle Zhipu glm-4-alltools scenario (range constraints)', () => {
const result = resolveParameters(
{ temperature: 1, top_p: 0.5 },
{
normalizeTemperature: true,
temperatureRange: { max: 0.99, min: 0.01 },
topPRange: { max: 0.99, min: 0.01 },
},
);
expect(result).toEqual({ temperature: 0.5, top_p: 0.5 });
});
it('should handle Groq scenario (temperature <= 0 becomes undefined)', () => {
// In Groq's case, they handle this in their own logic, but we can test the parameter resolver
const result = resolveParameters({ temperature: 0 }, { normalizeTemperature: false });
expect(result.temperature).toBe(0);
});
it('should handle Qwen range constraint scenario with multiple parameters', () => {
const result = resolveParameters(
{ presence_penalty: 1.5, temperature: 1.5, top_p: 0.8 },
{
normalizeTemperature: false,
presencePenaltyRange: { max: 2, min: -2 },
temperatureRange: { max: 2, min: 0 },
topPRange: { max: 1, min: 0 },
},
);
expect(result).toEqual({ presence_penalty: 1.5, temperature: 1.5, top_p: 0.8 });
});
});
describe('Edge cases', () => {
it('should handle temperature = 0', () => {
const result = resolveParameters({ temperature: 0 }, {});
expect(result).toEqual({ temperature: 0 });
});
it('should handle top_p = 0', () => {
const result = resolveParameters({ top_p: 0 }, {});
expect(result).toEqual({ top_p: 0 });
});
it('should handle temperature = undefined explicitly', () => {
const result = resolveParameters({ temperature: undefined, top_p: 0.9 }, {});
expect(result).toEqual({ top_p: 0.9 });
});
it('should handle both parameters undefined with conflict', () => {
const result = resolveParameters({}, { hasConflict: true });
expect(result).toEqual({});
});
});
});
describe('createParameterResolver', () => {
it('should create a resolver with predefined options', () => {
const resolver = createParameterResolver({
hasConflict: true,
normalizeTemperature: true,
preferTemperature: true,
});
const result = resolver({ temperature: 1, top_p: 0.9 });
expect(result).toEqual({ temperature: 0.5 });
});
it('should create a resolver with range constraints', () => {
const resolver = createParameterResolver({
normalizeTemperature: true,
temperatureRange: { max: 0.99, min: 0.01 },
topPRange: { max: 0.99, min: 0.01 },
});
const result = resolver({ temperature: 0.02, top_p: 0.005 });
expect(result).toEqual({ temperature: 0.01, top_p: 0.01 });
});
});
describe('MODEL_PARAMETER_CONFLICTS', () => {
describe('ANTHROPIC_CLAUDE_4_PLUS', () => {
it('should contain expected Claude 4+ models', () => {
expect(MODEL_PARAMETER_CONFLICTS.ANTHROPIC_CLAUDE_4_PLUS.has('claude-opus-4-1')).toBe(true);
expect(
MODEL_PARAMETER_CONFLICTS.ANTHROPIC_CLAUDE_4_PLUS.has('claude-opus-4-1-20250805'),
).toBe(true);
expect(
MODEL_PARAMETER_CONFLICTS.ANTHROPIC_CLAUDE_4_PLUS.has('claude-sonnet-4-5-20250929'),
).toBe(true);
});
it('should not contain Claude 3.x models', () => {
expect(MODEL_PARAMETER_CONFLICTS.ANTHROPIC_CLAUDE_4_PLUS.has('claude-3-opus-20240229')).toBe(
false,
);
expect(
MODEL_PARAMETER_CONFLICTS.ANTHROPIC_CLAUDE_4_PLUS.has('claude-3.5-sonnet-20240620'),
).toBe(false);
});
});
describe('BEDROCK_CLAUDE_4_PLUS', () => {
it('should contain both standard and Bedrock-specific model IDs', () => {
expect(MODEL_PARAMETER_CONFLICTS.BEDROCK_CLAUDE_4_PLUS.has('claude-opus-4-1')).toBe(true);
expect(
MODEL_PARAMETER_CONFLICTS.BEDROCK_CLAUDE_4_PLUS.has(
'anthropic.claude-opus-4-1-20250805-v1:0',
),
).toBe(true);
expect(
MODEL_PARAMETER_CONFLICTS.BEDROCK_CLAUDE_4_PLUS.has(
'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
),
).toBe(true);
});
it('should contain all Bedrock regional variants', () => {
expect(
MODEL_PARAMETER_CONFLICTS.BEDROCK_CLAUDE_4_PLUS.has(
'anthropic.claude-opus-4-20250514-v1:0',
),
).toBe(true);
expect(
MODEL_PARAMETER_CONFLICTS.BEDROCK_CLAUDE_4_PLUS.has(
'us.anthropic.claude-opus-4-20250514-v1:0',
),
).toBe(true);
});
});
});
@@ -0,0 +1,275 @@
/**
* Chat completion parameter configuration
*/
interface ParameterConfig {
/**
* Frequency penalty (reduces repetition)
*/
frequency_penalty?: number;
/**
* Maximum tokens to generate
*/
max_tokens?: number;
/**
* Presence penalty (reduces topic repetition)
*/
presence_penalty?: number;
/**
* Temperature value (0-2 range, controls randomness)
*/
temperature?: number;
/**
* Top P value (0-1 range, nucleus sampling)
*/
top_p?: number;
}
/**
* Range constraint for a numeric parameter
*/
interface RangeConstraint {
max?: number;
min?: number;
}
/**
* Parameter resolver options for model-specific constraints
*/
interface ParameterResolverOptions {
/**
* Frequency penalty range constraints
*/
frequencyPenaltyRange?: RangeConstraint;
/**
* Whether the model has a conflict between temperature and top_p
* If true, only one parameter can be set
* @default false
*/
hasConflict?: boolean;
/**
* Max tokens range constraints
*/
maxTokensRange?: RangeConstraint;
/**
* Whether to normalize temperature (divide by 2)
* @default true
*/
normalizeTemperature?: boolean;
/**
* Whether to prefer temperature over top_p when both are set and there's a conflict
* @default true
*/
preferTemperature?: boolean;
/**
* Presence penalty range constraints
*/
presencePenaltyRange?: RangeConstraint;
/**
* Temperature value range constraints
*/
temperatureRange?: RangeConstraint;
/**
* Top P value range constraints
*/
topPRange?: RangeConstraint;
}
/**
* Resolved parameters ready for API calls
*/
interface ResolvedParameters {
frequency_penalty?: number;
max_tokens?: number;
presence_penalty?: number;
temperature?: number;
top_p?: number;
}
/**
* Apply range constraints to a numeric value
*/
const applyRangeConstraint = (
value: number | undefined,
range: RangeConstraint | undefined,
): number | undefined => {
if (value === undefined || !range) return value;
let result = value;
if (range.min !== undefined) {
result = Math.max(range.min, result);
}
if (range.max !== undefined) {
result = Math.min(range.max, result);
}
return result;
};
/**
* Resolves and normalizes chat completion parameters based on model constraints
*
* This is a core utility for handling model-specific parameter requirements:
* - Parameter conflicts (e.g., Claude 4+ doesn't allow both temperature and top_p)
* - Value normalization (e.g., temperature / 2 for some models)
* - Range constraints (e.g., min/max values)
*
* @param config - The input parameter values
* @param options - Resolution options including conflict handling and normalization rules
* @returns Resolved parameters with only valid values
*
* @example
* // Basic usage with conflict (Claude Opus 4.1)
* resolveParameters(
* { temperature: 1, top_p: 0.9 },
* { hasConflict: true, preferTemperature: true }
* )
* // Returns: { temperature: 0.5 } // temperature normalized and top_p omitted
*
* @example
* // Without conflict
* resolveParameters(
* { temperature: 1, top_p: 0.9 },
* { hasConflict: false }
* )
* // Returns: { temperature: 0.5, top_p: 0.9 }
*
* @example
* // With range constraints (Zhipu glm-4-alltools)
* resolveParameters(
* { temperature: 1, top_p: 0.5 },
* {
* normalizeTemperature: true,
* temperatureRange: { min: 0.01, max: 0.99 },
* topPRange: { min: 0.01, max: 0.99 }
* }
* )
* // Returns: { temperature: 0.5, top_p: 0.5 }
*
* @example
* // With multiple parameters (Qwen)
* resolveParameters(
* { temperature: 1.5, top_p: 0.8, presence_penalty: 1.5 },
* {
* normalizeTemperature: false,
* temperatureRange: { min: 0, max: 2 },
* topPRange: { min: 0, max: 1 },
* presencePenaltyRange: { min: -2, max: 2 }
* }
* )
* // Returns: { temperature: 1.5, top_p: 0.8, presence_penalty: 1.5 }
*/
export const resolveParameters = (
config: ParameterConfig,
options: ParameterResolverOptions = {},
): ResolvedParameters => {
const {
hasConflict = false,
preferTemperature = true,
normalizeTemperature = true,
temperatureRange,
topPRange,
frequencyPenaltyRange,
presencePenaltyRange,
maxTokensRange,
} = options;
const { temperature, top_p, frequency_penalty, presence_penalty, max_tokens } = config;
const result: ResolvedParameters = {};
// Determine which parameters are provided
const shouldSetTemperature = temperature !== undefined;
const shouldSetTopP = top_p !== undefined;
// Handle temperature and top_p conflict
if (hasConflict) {
if (preferTemperature && shouldSetTemperature) {
// Set temperature only
let finalTemp =
normalizeTemperature && temperature !== undefined ? temperature / 2 : temperature;
result.temperature = applyRangeConstraint(finalTemp, temperatureRange);
} else if (shouldSetTopP) {
// Set top_p only
result.top_p = applyRangeConstraint(top_p, topPRange);
}
} else {
// No conflict: set both parameters if provided
if (shouldSetTemperature) {
let finalTemp =
normalizeTemperature && temperature !== undefined ? temperature / 2 : temperature;
result.temperature = applyRangeConstraint(finalTemp, temperatureRange);
}
if (shouldSetTopP) {
result.top_p = applyRangeConstraint(top_p, topPRange);
}
}
// Handle other parameters (no conflicts)
if (frequency_penalty !== undefined) {
result.frequency_penalty = applyRangeConstraint(frequency_penalty, frequencyPenaltyRange);
}
if (presence_penalty !== undefined) {
result.presence_penalty = applyRangeConstraint(presence_penalty, presencePenaltyRange);
}
if (max_tokens !== undefined) {
result.max_tokens = applyRangeConstraint(max_tokens, maxTokensRange);
}
return result;
};
/**
* Creates a parameter resolver with predefined model-specific rules
*
* @example
* // Create a resolver for Claude Opus 4.1
* const claudeOpusResolver = createParameterResolver({
* hasConflict: true,
* preferTemperature: true,
* normalizeTemperature: true
* });
*
* const params = claudeOpusResolver({ temperature: 1, top_p: 0.9 });
* // Returns: { temperature: 0.5 }
*/
export const createParameterResolver = (options: ParameterResolverOptions) => {
return (config: ParameterConfig): ResolvedParameters => {
return resolveParameters(config, options);
};
};
/**
* Common model sets that have parameter conflicts
*/
export const MODEL_PARAMETER_CONFLICTS = {
/**
* Claude models after Opus 4.1 that don't allow both temperature and top_p
*/
ANTHROPIC_CLAUDE_4_PLUS: new Set([
'claude-opus-4-1',
'claude-opus-4-1-20250805',
'claude-sonnet-4-5-20250929',
]),
/**
* Bedrock Claude 4+ models (including Bedrock-specific model IDs)
*/
BEDROCK_CLAUDE_4_PLUS: new Set([
'claude-opus-4-1',
'claude-opus-4-1-20250805',
'claude-opus-4-20250514',
'claude-sonnet-4-20250514',
'claude-sonnet-4-5-20250929',
// Bedrock model IDs
'anthropic.claude-opus-4-1-20250805-v1:0',
'us.anthropic.claude-opus-4-1-20250805-v1:0',
'anthropic.claude-opus-4-20250514-v1:0',
'us.anthropic.claude-opus-4-20250514-v1:0',
'anthropic.claude-sonnet-4-20250514-v1:0',
'us.anthropic.claude-sonnet-4-20250514-v1:0',
'anthropic.claude-sonnet-4-5-20250929-v1:0',
'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
]),
};
@@ -1,12 +1,8 @@
// @vitest-environment node
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as desensitizeTool from '../utils/desensitizeUrl';
import {
CloudflareStreamTransformer,
desensitizeCloudflareUrl,
fillUrl,
} from './cloudflareHelpers';
import * as desensitizeTool from '../../utils/desensitizeUrl';
import { CloudflareStreamTransformer, desensitizeCloudflareUrl, fillUrl } from './cloudflare';
afterEach(() => {
vi.restoreAllMocks();
@@ -1,4 +1,4 @@
import { desensitizeUrl } from '../utils/desensitizeUrl';
import { desensitizeUrl } from '../../utils/desensitizeUrl';
class CloudflareStreamTransformer {
private textDecoder = new TextDecoder();
+1 -1
View File
@@ -1,4 +1,5 @@
export * from './core/BaseAI';
export { pruneReasoningPayload } from './core/contextBuilders/openai';
export { ModelRuntime } from './core/ModelRuntime';
export { createOpenAICompatibleRuntime } from './core/openaiCompatibleFactory';
export * from './core/RouterRuntime';
@@ -36,5 +37,4 @@ export * from './types/error';
export { AgentRuntimeError } from './utils/createError';
export { getModelPropertyWithFallback } from './utils/getFallbackModelProperty';
export { getModelPricing } from './utils/getModelPricing';
export { pruneReasoningPayload } from './utils/openaiHelpers';
export { parseDataUri } from './utils/uriParser';
@@ -1,7 +1,7 @@
import Anthropic from '@anthropic-ai/sdk';
import { buildAnthropicMessages } from '../../core/contextBuilders/anthropic';
import { GenerateObjectOptions, GenerateObjectPayload } from '../../types';
import { buildAnthropicMessages } from '../../utils/anthropicHelpers';
/**
* Generate structured output using Anthropic Claude API with Function Calling
@@ -2,7 +2,7 @@
import { ChatCompletionTool, ChatStreamPayload } from '@lobechat/model-runtime';
import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as anthropicHelpers from '../../utils/anthropicHelpers';
import * as anthropicHelpers from '../../core/contextBuilders/anthropic';
import * as debugStreamModule from '../../utils/debugStream';
import { LobeAnthropicAI } from './index';
@@ -2,6 +2,8 @@ import Anthropic, { ClientOptions } from '@anthropic-ai/sdk';
import { ModelProvider } from 'model-bank';
import { LobeRuntimeAI } from '../../core/BaseAI';
import { buildAnthropicMessages, buildAnthropicTools } from '../../core/contextBuilders/anthropic';
import { MODEL_PARAMETER_CONFLICTS, resolveParameters } from '../../core/parameterResolver';
import { AnthropicStream } from '../../core/streams';
import {
type ChatCompletionErrorPayload,
@@ -11,7 +13,6 @@ import {
GenerateObjectPayload,
} from '../../types';
import { AgentRuntimeErrorType } from '../../types/error';
import { buildAnthropicMessages, buildAnthropicTools } from '../../utils/anthropicHelpers';
import { AgentRuntimeError } from '../../utils/createError';
import { debugStream } from '../../utils/debugStream';
import { desensitizeUrl } from '../../utils/desensitizeUrl';
@@ -31,13 +32,6 @@ type anthropicTools = Anthropic.Tool | Anthropic.WebSearchTool20250305;
const modelsWithSmallContextWindow = new Set(['claude-3-opus-20240229', 'claude-3-haiku-20240307']);
// models after Opus 4.1 that don't allow both temperature and top_p parameters
const modelsWithTempAndTopPConflict = new Set([
'claude-opus-4-1',
'claude-opus-4-1-20250805',
'claude-sonnet-4-5-20250929',
]);
const DEFAULT_BASE_URL = 'https://api.anthropic.com';
const DEFAULT_CACHE_TTL = '5m' as const;
@@ -254,9 +248,12 @@ export class LobeAnthropicAI implements LobeRuntimeAI {
} satisfies Anthropic.MessageCreateParams;
}
// For Opus 4.1 models, we can only set either temperature OR top_p, not both
const isTempAndTopPConflict = modelsWithTempAndTopPConflict.has(model);
const shouldSetTemperature = payload.temperature !== undefined;
// Resolve temperature and top_p parameters based on model constraints
const hasConflict = MODEL_PARAMETER_CONFLICTS.ANTHROPIC_CLAUDE_4_PLUS.has(model);
const resolvedParams = resolveParameters(
{ temperature, top_p },
{ hasConflict, normalizeTemperature: true, preferTemperature: true },
);
return {
// claude 3 series model hax max output token of 4096, 3.x series has 8192
@@ -265,17 +262,9 @@ export class LobeAnthropicAI implements LobeRuntimeAI {
messages: postMessages,
model,
system: systemPrompts,
// For Opus 4.1 models: prefer temperature over top_p if both are provided
temperature: isTempAndTopPConflict
? shouldSetTemperature
? temperature / 2
: undefined
: payload.temperature !== undefined
? temperature / 2
: undefined,
temperature: resolvedParams.temperature,
tools: postTools,
// For Opus 4.1 models: only set top_p if temperature is not set
top_p: isTempAndTopPConflict ? (shouldSetTemperature ? undefined : top_p) : top_p,
top_p: resolvedParams.top_p,
} satisfies Anthropic.MessageCreateParams;
}
@@ -442,7 +442,7 @@ describe('LobeAzureOpenAI', () => {
.spyOn(instance['client'].images, 'edit')
.mockResolvedValue({ data: [{ url }] } as any);
const helpers = await import('../../utils/openaiHelpers');
const helpers = await import('../../core/contextBuilders/openai');
vi.spyOn(helpers, 'convertImageUrlToFile').mockResolvedValue({} as any);
const res = await instance.createImage({
@@ -462,7 +462,7 @@ describe('LobeAzureOpenAI', () => {
.spyOn(instance['client'].images, 'edit')
.mockResolvedValue({ data: [{ url }] } as any);
const helpers = await import('../../utils/openaiHelpers');
const helpers = await import('../../core/contextBuilders/openai');
const spy = vi.spyOn(helpers, 'convertImageUrlToFile').mockResolvedValue({} as any);
await instance.createImage({
@@ -5,6 +5,7 @@ import type { Stream } from 'openai/streaming';
import { systemToUserModels } from '../../const/models';
import { LobeRuntimeAI } from '../../core/BaseAI';
import { convertImageUrlToFile, convertOpenAIMessages } from '../../core/contextBuilders/openai';
import { transformResponseToStream } from '../../core/openaiCompatibleFactory';
import { OpenAIStream } from '../../core/streams';
import {
@@ -18,7 +19,6 @@ import { AgentRuntimeErrorType } from '../../types/error';
import { CreateImagePayload, CreateImageResponse } from '../../types/image';
import { AgentRuntimeError } from '../../utils/createError';
import { debugStream } from '../../utils/debugStream';
import { convertImageUrlToFile, convertOpenAIMessages } from '../../utils/openaiHelpers';
import { StreamingResponse } from '../../utils/response';
import { sanitizeError } from '../../utils/sanitizeError';
@@ -2,6 +2,7 @@ import type { ChatModelCard } from '@lobechat/types';
import { ModelProvider } from 'model-bank';
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
import { resolveParameters } from '../../core/parameterResolver';
import { ChatStreamPayload } from '../../types';
export interface BaichuanModelCard {
@@ -31,11 +32,12 @@ export const LobeBaichuanAI = createOpenAICompatibleRuntime({
]
: tools;
// Resolve parameters with normalization
const resolvedParams = resolveParameters({ temperature }, { normalizeTemperature: true });
return {
...rest,
// [baichuan] frequency_penalty must be between 1 and 2.
frequency_penalty: undefined,
temperature: temperature !== undefined ? temperature / 2 : undefined,
temperature: resolvedParams.temperature,
tools: baichuanTools,
} as any;
},
@@ -258,6 +258,173 @@ describe('LobeBedrockAI', () => {
delete process.env.DEBUG_BEDROCK_CHAT_COMPLETION;
});
describe('Parameter conflict handling for Claude 4+ models', () => {
it('should send only temperature for Claude 4+ models when both temperature and top_p are provided', async () => {
// Arrange
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue('Hello, world!');
controller.close();
},
});
const mockResponse = Promise.resolve(mockStream);
(instance['client'].send as Mock).mockResolvedValue(mockResponse);
// Act
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'anthropic.claude-opus-4-1-20250805-v1:0',
temperature: 0.8,
top_p: 0.9,
});
// Assert
expect(InvokeModelWithResponseStreamCommand).toHaveBeenCalledWith({
accept: 'application/json',
body: JSON.stringify({
anthropic_version: 'bedrock-2023-05-31',
max_tokens: 4096,
messages: [{ content: 'Hello', role: 'user' }],
temperature: 0.4, // temperature / 2, top_p omitted due to conflict
}),
contentType: 'application/json',
modelId: 'anthropic.claude-opus-4-1-20250805-v1:0',
});
});
it('should send only top_p for Claude 4+ models when temperature is not provided', async () => {
// Arrange
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue('Hello, world!');
controller.close();
},
});
const mockResponse = Promise.resolve(mockStream);
(instance['client'].send as Mock).mockResolvedValue(mockResponse);
// Act
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'anthropic.claude-sonnet-4-20250514-v1:0',
top_p: 0.9,
});
// Assert
expect(InvokeModelWithResponseStreamCommand).toHaveBeenCalledWith({
accept: 'application/json',
body: JSON.stringify({
anthropic_version: 'bedrock-2023-05-31',
max_tokens: 4096,
messages: [{ content: 'Hello', role: 'user' }],
top_p: 0.9, // temperature omitted since not provided
}),
contentType: 'application/json',
modelId: 'anthropic.claude-sonnet-4-20250514-v1:0',
});
});
it('should send both temperature and top_p for non-Claude-4+ models', async () => {
// Arrange
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue('Hello, world!');
controller.close();
},
});
const mockResponse = Promise.resolve(mockStream);
(instance['client'].send as Mock).mockResolvedValue(mockResponse);
// Act
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'anthropic.claude-3-5-sonnet-20241022-v2:0',
temperature: 0.8,
top_p: 0.9,
});
// Assert
expect(InvokeModelWithResponseStreamCommand).toHaveBeenCalledWith({
accept: 'application/json',
body: JSON.stringify({
anthropic_version: 'bedrock-2023-05-31',
max_tokens: 4096,
messages: [{ content: 'Hello', role: 'user' }],
temperature: 0.4, // temperature / 2
top_p: 0.9, // both parameters allowed for older models
}),
contentType: 'application/json',
modelId: 'anthropic.claude-3-5-sonnet-20241022-v2:0',
});
});
it('should handle US region Claude 4+ model IDs correctly', async () => {
// Arrange
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue('Hello, world!');
controller.close();
},
});
const mockResponse = Promise.resolve(mockStream);
(instance['client'].send as Mock).mockResolvedValue(mockResponse);
// Act
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
temperature: 0.6,
top_p: 0.8,
});
// Assert
expect(InvokeModelWithResponseStreamCommand).toHaveBeenCalledWith({
accept: 'application/json',
body: JSON.stringify({
anthropic_version: 'bedrock-2023-05-31',
max_tokens: 4096,
messages: [{ content: 'Hello', role: 'user' }],
temperature: 0.3, // temperature / 2, top_p omitted due to conflict
}),
contentType: 'application/json',
modelId: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0',
});
});
it('should handle standard Claude 4+ model IDs (non-Bedrock format)', async () => {
// Arrange
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue('Hello, world!');
controller.close();
},
});
const mockResponse = Promise.resolve(mockStream);
(instance['client'].send as Mock).mockResolvedValue(mockResponse);
// Act
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'claude-opus-4-1',
temperature: 0.7,
top_p: 0.95,
});
// Assert
expect(InvokeModelWithResponseStreamCommand).toHaveBeenCalledWith({
accept: 'application/json',
body: JSON.stringify({
anthropic_version: 'bedrock-2023-05-31',
max_tokens: 4096,
messages: [{ content: 'Hello', role: 'user' }],
temperature: 0.35, // temperature / 2, top_p omitted due to conflict
}),
contentType: 'application/json',
modelId: 'claude-opus-4-1',
});
});
});
it('should handle errors and throw AgentRuntimeError', async () => {
// Arrange
const errorMessage = 'An error occurred';
@@ -6,6 +6,8 @@ import {
import { ModelProvider } from 'model-bank';
import { LobeRuntimeAI } from '../../core/BaseAI';
import { buildAnthropicMessages, buildAnthropicTools } from '../../core/contextBuilders/anthropic';
import { MODEL_PARAMETER_CONFLICTS, resolveParameters } from '../../core/parameterResolver';
import {
AWSBedrockClaudeStream,
AWSBedrockLlamaStream,
@@ -19,7 +21,6 @@ import {
EmbeddingsPayload,
} from '../../types';
import { AgentRuntimeErrorType } from '../../types/error';
import { buildAnthropicMessages, buildAnthropicTools } from '../../utils/anthropicHelpers';
import { AgentRuntimeError } from '../../utils/createError';
import { debugStream } from '../../utils/debugStream';
import { StreamingResponse } from '../../utils/response';
@@ -151,6 +152,13 @@ export class LobeBedrockAI implements LobeRuntimeAI {
const system_message = messages.find((m) => m.role === 'system');
const user_messages = messages.filter((m) => m.role !== 'system');
// Resolve temperature and top_p parameters based on model constraints
const hasConflict = MODEL_PARAMETER_CONFLICTS.BEDROCK_CLAUDE_4_PLUS.has(model);
const resolvedParams = resolveParameters(
{ temperature, top_p },
{ hasConflict, normalizeTemperature: true, preferTemperature: true },
);
const command = new InvokeModelWithResponseStreamCommand({
accept: 'application/json',
body: JSON.stringify({
@@ -158,9 +166,9 @@ export class LobeBedrockAI implements LobeRuntimeAI {
max_tokens: max_tokens || 4096,
messages: await buildAnthropicMessages(user_messages),
system: system_message?.content as string,
temperature: temperature / 2,
temperature: resolvedParams.temperature,
tools: buildAnthropicTools(tools),
top_p: top_p,
top_p: resolvedParams.top_p,
}),
contentType: 'application/json',
modelId: model,
@@ -3,14 +3,14 @@ import { ModelProvider } from 'model-bank';
import { LobeRuntimeAI } from '../../core/BaseAI';
import { createCallbacksTransformer } from '../../core/streams';
import { ChatMethodOptions, ChatStreamPayload } from '../../types';
import { AgentRuntimeErrorType } from '../../types/error';
import {
CloudflareStreamTransformer,
DEFAULT_BASE_URL_PREFIX,
desensitizeCloudflareUrl,
fillUrl,
} from '../../utils/cloudflareHelpers';
} from '../../core/streams/cloudflare';
import { ChatMethodOptions, ChatStreamPayload } from '../../types';
import { AgentRuntimeErrorType } from '../../types/error';
import { AgentRuntimeError } from '../../utils/createError';
import { debugStream } from '../../utils/debugStream';
import { StreamingResponse } from '../../utils/response';
@@ -2,6 +2,7 @@ import type { ChatModelCard } from '@lobechat/types';
import { ModelProvider } from 'model-bank';
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
import { resolveParameters } from '../../core/parameterResolver';
export interface CohereModelCard {
context_length: number;
@@ -18,17 +19,20 @@ export const LobeCohereAI = createOpenAICompatibleRuntime({
handlePayload: (payload) => {
const { frequency_penalty, presence_penalty, top_p, ...rest } = payload;
// Resolve parameters with range constraints
const resolvedParams = resolveParameters(
{ frequency_penalty, presence_penalty, top_p },
{
frequencyPenaltyRange: { max: 1, min: 0 },
normalizeTemperature: false,
presencePenaltyRange: { max: 1, min: 0 },
topPRange: { max: 1, min: 0 },
},
);
return {
...rest,
frequency_penalty:
frequency_penalty !== undefined && frequency_penalty > 0 && frequency_penalty <= 1
? frequency_penalty
: undefined,
presence_penalty:
presence_penalty !== undefined && presence_penalty > 0 && presence_penalty <= 1
? presence_penalty
: undefined,
top_p: top_p !== undefined && top_p > 0 && top_p < 1 ? top_p : undefined,
...resolvedParams,
} as any;
},
noUserId: true,
@@ -1,9 +1,9 @@
import { ModelProvider } from 'model-bank';
import { pruneReasoningPayload } from '../../core/contextBuilders/openai';
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
import { AgentRuntimeErrorType } from '../../types/error';
import { processMultiProviderModelList } from '../../utils/modelParse';
import { pruneReasoningPayload } from '../../utils/openaiHelpers';
export interface GithubModelCard {
capabilities: string[];
@@ -2,6 +2,7 @@ import type { ChatModelCard } from '@lobechat/types';
import { ModelProvider } from 'model-bank';
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
import { resolveParameters } from '../../core/parameterResolver';
import { AgentRuntimeErrorType } from '../../types/error';
export interface GroqModelCard {
@@ -19,11 +20,17 @@ export const LobeGroq = createOpenAICompatibleRuntime({
},
handlePayload: (payload) => {
const { temperature, ...restPayload } = payload;
// Groq doesn't support temperature <= 0, set to undefined in that case
const resolvedParams = resolveParameters({ temperature }, { normalizeTemperature: false });
return {
...restPayload,
stream: payload.stream ?? true,
temperature: temperature <= 0 ? undefined : temperature,
temperature:
resolvedParams.temperature !== undefined && resolvedParams.temperature <= 0
? undefined
: resolvedParams.temperature,
} as any;
},
},
@@ -1,6 +1,7 @@
import { minimax as minimaxChatModels , ModelProvider } from 'model-bank';
import { ModelProvider, minimax as minimaxChatModels } from 'model-bank';
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
import { resolveParameters } from '../../core/parameterResolver';
import { createMiniMaxImage } from './createImage';
export const getMinimaxMaxOutputs = (modelId: string): number | undefined => {
@@ -23,14 +24,31 @@ export const LobeMinimaxAI = createOpenAICompatibleRuntime({
]
: tools;
// Resolve parameters with constraints
const resolvedParams = resolveParameters(
{
max_tokens: max_tokens !== undefined ? max_tokens : getMinimaxMaxOutputs(payload.model),
temperature,
top_p,
},
{
normalizeTemperature: true,
topPRange: { max: 1, min: 0 },
},
);
// Minimax doesn't support temperature <= 0
const finalTemperature =
resolvedParams.temperature !== undefined && resolvedParams.temperature <= 0
? undefined
: resolvedParams.temperature;
return {
...params,
frequency_penalty: undefined,
max_tokens: max_tokens !== undefined ? max_tokens : getMinimaxMaxOutputs(payload.model),
presence_penalty: undefined,
temperature: temperature === undefined || temperature <= 0 ? undefined : temperature / 2,
max_tokens: resolvedParams.max_tokens,
temperature: finalTemperature,
tools: minimaxTools,
top_p: top_p !== undefined && top_p > 0 && top_p <= 1 ? top_p : undefined,
top_p: resolvedParams.top_p,
} as any;
},
},
@@ -2,6 +2,7 @@ import type { ChatModelCard } from '@lobechat/types';
import { ModelProvider } from 'model-bank';
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
import { resolveParameters } from '../../core/parameterResolver';
export interface MistralModelCard {
capabilities: {
@@ -19,15 +20,21 @@ export const LobeMistralAI = createOpenAICompatibleRuntime({
// Mistral API does not support stream_options: { include_usage: true }
// refs: https://github.com/lobehub/lobe-chat/issues/6825
excludeUsage: true,
handlePayload: (payload) => ({
...(payload.max_tokens !== undefined && { max_tokens: payload.max_tokens }),
messages: payload.messages as any,
model: payload.model,
stream: true,
temperature: payload.temperature !== undefined ? payload.temperature / 2 : undefined,
...(payload.tools && { tools: payload.tools }),
top_p: payload.top_p,
}),
handlePayload: (payload) => {
// Resolve parameters with normalization
const resolvedParams = resolveParameters(
{ max_tokens: payload.max_tokens, temperature: payload.temperature, top_p: payload.top_p },
{ normalizeTemperature: true },
);
return {
...resolvedParams,
messages: payload.messages as any,
model: payload.model,
stream: true,
...(payload.tools && { tools: payload.tools }),
};
},
noUserId: true,
},
debug: {
@@ -1,6 +1,7 @@
import { ModelProvider } from 'model-bank';
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
import { resolveParameters } from '../../core/parameterResolver';
import { ChatStreamPayload } from '../../types';
import { MODEL_LIST_CONFIGS, processModelList } from '../../utils/modelParse';
@@ -34,10 +35,13 @@ export const LobeMoonshotAI = createOpenAICompatibleRuntime({
]
: tools;
// Resolve parameters with normalization
const resolvedParams = resolveParameters({ temperature }, { normalizeTemperature: true });
return {
...rest,
messages: filteredMessages,
temperature: temperature !== undefined ? temperature / 2 : undefined,
temperature: resolvedParams.temperature,
tools: moonshotTools,
} as any;
},
@@ -1,10 +1,10 @@
import { ModelProvider } from 'model-bank';
import { responsesAPIModels } from '../../const/models';
import { pruneReasoningPayload } from '../../core/contextBuilders/openai';
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
import { ChatStreamPayload } from '../../types';
import { processMultiProviderModelList } from '../../utils/modelParse';
import { pruneReasoningPayload } from '../../utils/openaiHelpers';
export interface OpenAIModelCard {
id: string;
@@ -43,10 +43,10 @@ export const LobeOpenAI = createOpenAICompatibleRuntime({
frequency_penalty: undefined,
model,
presence_penalty: undefined,
...(enableServiceTierFlex && supportsFlexTier(model) && { service_tier: 'flex' }),
stream: payload.stream ?? true,
temperature: undefined,
top_p: undefined,
...(enableServiceTierFlex && supportsFlexTier(model) && { service_tier: 'flex' }),
...(oaiSearchContextSize && {
web_search_options: {
search_context_size: oaiSearchContextSize,
@@ -81,14 +81,14 @@ export const LobeOpenAI = createOpenAICompatibleRuntime({
const openaiTools = enabledSearch
? [
...(tools || []),
{
type: 'web_search',
...(oaiSearchContextSize && {
search_context_size: oaiSearchContextSize,
}),
},
]
...(tools || []),
{
type: 'web_search',
...(oaiSearchContextSize && {
search_context_size: oaiSearchContextSize,
}),
},
]
: tools;
if (prunePrefixes.some((prefix) => model.startsWith(prefix))) {
@@ -2,31 +2,39 @@ import { ModelProvider } from 'model-bank';
import OpenAI from 'openai';
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
import { resolveParameters } from '../../core/parameterResolver';
import { ChatStreamPayload } from '../../types';
export const LobePerplexityAI = createOpenAICompatibleRuntime({
baseURL: 'https://api.perplexity.ai',
chatCompletion: {
handlePayload: (payload: ChatStreamPayload) => {
// Set a default frequency penalty value greater than 0
const { presence_penalty, frequency_penalty, stream = true, temperature, ...res } = payload;
let param;
// Resolve parameters with constraints
const resolvedParams = resolveParameters(
{
frequency_penalty: presence_penalty !== 0 ? undefined : frequency_penalty || 1,
presence_penalty: presence_penalty !== 0 ? presence_penalty : undefined,
temperature,
},
{
normalizeTemperature: false,
},
);
// Ensure we are only have one frequency_penalty or frequency_penalty
if (presence_penalty !== 0) {
param = { presence_penalty };
} else {
const defaultFrequencyPenalty = 1;
param = { frequency_penalty: frequency_penalty || defaultFrequencyPenalty };
}
// Perplexity doesn't support temperature >= 2
const finalTemperature =
resolvedParams.temperature !== undefined && resolvedParams.temperature >= 2
? undefined
: resolvedParams.temperature;
return {
...res,
...param,
frequency_penalty: resolvedParams.frequency_penalty,
presence_penalty: resolvedParams.presence_penalty,
stream,
temperature: temperature >= 2 ? undefined : temperature,
temperature: finalTemperature,
} as OpenAI.ChatCompletionCreateParamsStreaming;
},
},
@@ -1,6 +1,7 @@
import { ModelProvider } from 'model-bank';
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
import { resolveParameters } from '../../core/parameterResolver';
import { QwenAIStream } from '../../core/streams';
import { processMultiProviderModelList } from '../../utils/modelParse';
import { createQwenImage } from './createImage';
@@ -28,49 +29,46 @@ export const LobeQwenAI = createOpenAICompatibleRuntime({
const { model, presence_penalty, temperature, thinking, top_p, enabledSearch, ...rest } =
payload;
// Resolve parameters with model-specific constraints
const resolvedParams = resolveParameters(
{ presence_penalty, temperature, top_p },
{
normalizeTemperature: false,
presencePenaltyRange: QwenLegacyModels.has(model) ? undefined : { max: 2, min: -2 },
temperatureRange: { max: 2, min: 0 },
topPRange:
model.startsWith('qvq') || model.startsWith('qwen-vl')
? { max: 1, min: 0 }
: { max: 1, min: 0 },
},
);
return {
...rest,
...(model.includes('-thinking')
? {
enable_thinking: true,
thinking_budget:
thinking?.budget_tokens === 0 ? 0 : thinking?.budget_tokens || undefined,
}
: ['qwen3', 'qwen-turbo', 'qwen-plus', 'deepseek-v3.1'].some((keyword) =>
model.toLowerCase().includes(keyword),
)
? {
enable_thinking: thinking !== undefined ? thinking.type === 'enabled' : false,
enable_thinking: true,
thinking_budget:
thinking?.budget_tokens === 0 ? 0 : thinking?.budget_tokens || undefined,
}
: ['qwen3', 'qwen-turbo', 'qwen-plus', 'deepseek-v3.1'].some((keyword) =>
model.toLowerCase().includes(keyword),
)
? {
enable_thinking: thinking !== undefined ? thinking.type === 'enabled' : false,
thinking_budget:
thinking?.budget_tokens === 0 ? 0 : thinking?.budget_tokens || undefined,
}
: {}),
frequency_penalty: undefined,
model,
presence_penalty: QwenLegacyModels.has(model)
? undefined
: presence_penalty !== undefined && presence_penalty >= -2 && presence_penalty <= 2
? presence_penalty
: undefined,
presence_penalty: resolvedParams.presence_penalty,
stream: true,
temperature:
temperature !== undefined && temperature >= 0 && temperature < 2
? temperature
: undefined,
...(model.startsWith('qvq') || model.startsWith('qwen-vl')
? {
top_p: top_p !== undefined && top_p > 0 && top_p <= 1 ? top_p : undefined,
}
: {
top_p: top_p !== undefined && top_p > 0 && top_p < 1 ? top_p : undefined,
}),
temperature: resolvedParams.temperature,
top_p: resolvedParams.top_p,
...(enabledSearch && {
enable_search: enabledSearch,
search_options: {
/*
enable_citation: true,
enable_source: true,
*/
search_strategy: process.env.QWEN_SEARCH_STRATEGY || 'standard', // standard or pro
},
}),
@@ -29,7 +29,7 @@ export const LobeSearch1API = createOpenAICompatibleRuntime({
...res,
...param,
stream,
temperature: temperature >= 2 ? undefined : temperature,
temperature: temperature !== undefined && temperature >= 2 ? undefined : temperature,
} as OpenAI.ChatCompletionCreateParamsStreaming;
},
},
@@ -1,8 +1,8 @@
import type { ChatModelCard } from '@lobechat/types';
import { ModelProvider } from 'model-bank';
import { convertSenseNovaMessage } from '../../core/contextBuilders/sensenova';
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
import { convertSenseNovaMessage } from '../../utils/sensenovaHelpers';
export interface SenseNovaModelCard {
id: string;
@@ -1,6 +1,7 @@
import { ModelProvider } from 'model-bank';
import { createOpenAICompatibleRuntime } from '../../core/openaiCompatibleFactory';
import { resolveParameters } from '../../core/parameterResolver';
import { OpenAIStream } from '../../core/streams/openai';
import { convertIterableToStream } from '../../core/streams/protocol';
import { MODEL_LIST_CONFIGS, processModelList } from '../../utils/modelParse';
@@ -33,30 +34,32 @@ export const LobeZhipuAI = createOpenAICompatibleRuntime({
]
: tools;
// Resolve parameters based on model-specific constraints
const resolvedParams = resolveParameters(
{ max_tokens, temperature, top_p },
{
// max_tokens constraints
maxTokensRange: model.includes('glm-4v')
? { max: 1024 }
: model === 'glm-zero-preview'
? { max: 15_300 }
: undefined,
normalizeTemperature: true,
// glm-4-alltools has stricter temperature and top_p constraints
...(model === 'glm-4-alltools' && {
temperatureRange: { max: 0.99, min: 0.01 },
topPRange: { max: 0.99, min: 0.01 },
}),
},
);
return {
...rest,
max_tokens:
max_tokens === undefined
? undefined
: (model.includes('glm-4v') && Math.min(max_tokens, 1024)) ||
(model === 'glm-zero-preview' && Math.min(max_tokens, 15_300)) ||
max_tokens,
...resolvedParams,
model,
stream: true,
thinking: model.includes('-4.5') ? { type: thinking?.type } : undefined,
tools: zhipuTools,
...(model === 'glm-4-alltools'
? {
temperature:
temperature !== undefined
? Math.max(0.01, Math.min(0.99, temperature / 2))
: undefined,
top_p: top_p !== undefined ? Math.max(0.01, Math.min(0.99, top_p)) : undefined,
}
: {
temperature: temperature !== undefined ? temperature / 2 : undefined,
top_p,
}),
} as any;
},
handleStream: (stream, { callbacks, inputStartAt }) => {
+1 -1
View File
@@ -108,7 +108,7 @@ export interface ChatStreamPayload {
* @title 生成文本的随机度量,用于控制文本的创造性和多样性
* @default 1
*/
temperature: number;
temperature?: number;
text?: {
verbosity?: 'low' | 'medium' | 'high';
};
-2
View File
@@ -196,8 +196,6 @@ export const chatTopic: StateCreator<
enable ? [SWR_USE_FETCH_TOPIC, sessionId] : null,
async ([, sessionId]: [string, string]) => topicService.getTopics({ sessionId }),
{
suspense: true,
fallbackData: [],
onSuccess: (topics) => {
const nextMap = { ...get().topicMaps, [sessionId]: topics };