mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +00:00
Merge branch 'main' into fix/fts-non-fatal-in-analyze
This commit is contained in:
commit
801ad355c4
8 changed files with 955 additions and 25 deletions
|
|
@ -341,6 +341,7 @@ export const SettingsPanel = ({
|
|||
'openrouter',
|
||||
'minimax',
|
||||
'glm',
|
||||
'deepseek',
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -433,7 +434,9 @@ export const SettingsPanel = ({
|
|||
? '⚡'
|
||||
: provider === 'glm'
|
||||
? '🔮'
|
||||
: '☁️'}
|
||||
: provider === 'deepseek'
|
||||
? '🐋'
|
||||
: '☁️'}
|
||||
</div>
|
||||
<span className="font-medium">{getProviderDisplayName(provider)}</span>
|
||||
</button>
|
||||
|
|
@ -856,6 +859,43 @@ export const SettingsPanel = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* DeepSeek Settings */}
|
||||
{settings.activeProvider === 'deepseek' && (
|
||||
<ProviderConfigCard
|
||||
title="DeepSeek"
|
||||
apiKey={{
|
||||
value: settings.deepseek?.apiKey ?? '',
|
||||
placeholder: 'Enter your DeepSeek API key',
|
||||
helperText: 'Get your API key from',
|
||||
helperLink: 'https://platform.deepseek.com/api_keys',
|
||||
helperLinkLabel: 'DeepSeek Platform',
|
||||
isVisible: !!showApiKey['deepseek'],
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
deepseek: { ...prev.deepseek!, apiKey: value },
|
||||
})),
|
||||
onToggleVisibility: () => toggleApiKeyVisibility('deepseek'),
|
||||
}}
|
||||
model={{
|
||||
value: settings.deepseek?.model ?? 'deepseek-v4-flash',
|
||||
placeholder: 'e.g., deepseek-v4-flash, deepseek-v4-pro, deepseek-chat',
|
||||
onChange: (value) =>
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
deepseek: { ...prev.deepseek!, model: value },
|
||||
})),
|
||||
helperText:
|
||||
'deepseek-v4-flash (default), deepseek-v4-pro, deepseek-chat (V3), deepseek-reasoner (R1)',
|
||||
}}
|
||||
>
|
||||
<p className="text-xs text-text-muted">
|
||||
Compatible via OpenAI API format. The deepseek-reasoner model uses thinking mode and
|
||||
requires round-tripping reasoning content.
|
||||
</p>
|
||||
</ProviderConfigCard>
|
||||
)}
|
||||
|
||||
{/* GLM Settings */}
|
||||
{settings.activeProvider === 'glm' && (
|
||||
<div className="animate-fade-in space-y-4">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@
|
|||
*/
|
||||
|
||||
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
||||
import { SystemMessage } from '@langchain/core/messages';
|
||||
import {
|
||||
SystemMessage,
|
||||
HumanMessage,
|
||||
AIMessage,
|
||||
ToolMessage,
|
||||
type BaseMessage,
|
||||
} from '@langchain/core/messages';
|
||||
import { ChatOpenAI, AzureChatOpenAI } from '@langchain/openai';
|
||||
import { ChatGoogleGenerativeAI } from '@langchain/google-genai';
|
||||
import { ChatAnthropic } from '@langchain/anthropic';
|
||||
|
|
@ -23,10 +29,17 @@ import type {
|
|||
OpenRouterConfig,
|
||||
MiniMaxConfig,
|
||||
GLMConfig,
|
||||
DeepSeekConfig,
|
||||
AgentStreamChunk,
|
||||
AgentHistoryMessage,
|
||||
} from './types';
|
||||
import { type CodebaseContext, buildDynamicSystemPrompt } from './context-builder';
|
||||
import { DEFAULT_OLLAMA_BASE_URL, DEFAULT_OPENROUTER_BASE_URL } from '../../config/ui-constants';
|
||||
import {
|
||||
DeepSeekChatOpenAI,
|
||||
normalizeMessageContent,
|
||||
normalizeToolCalls,
|
||||
} from './deepseek-chat-model';
|
||||
|
||||
/**
|
||||
* System prompt for the Graph RAG agent
|
||||
|
|
@ -124,6 +137,7 @@ When generating diagrams:
|
|||
BAD: A[User's Data] --> B(Process & Save)
|
||||
GOOD: A["User Data"] --> B["Process and Save"]
|
||||
`;
|
||||
|
||||
export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
||||
switch (config.provider) {
|
||||
case 'openai': {
|
||||
|
|
@ -264,6 +278,26 @@ export const createChatModel = (config: ProviderConfig): BaseChatModel => {
|
|||
});
|
||||
}
|
||||
|
||||
case 'deepseek': {
|
||||
const deepseekConfig = config as DeepSeekConfig;
|
||||
|
||||
if (!deepseekConfig.apiKey || deepseekConfig.apiKey.trim() === '') {
|
||||
throw new Error('DeepSeek API key is required but was not provided');
|
||||
}
|
||||
|
||||
return new DeepSeekChatOpenAI({
|
||||
apiKey: deepseekConfig.apiKey,
|
||||
modelName: deepseekConfig.model,
|
||||
temperature: deepseekConfig.temperature ?? 0.1,
|
||||
maxTokens: deepseekConfig.maxTokens,
|
||||
configuration: {
|
||||
apiKey: deepseekConfig.apiKey,
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
},
|
||||
streaming: true,
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${(config as any).provider}`);
|
||||
}
|
||||
|
|
@ -324,11 +358,65 @@ export const createGraphRAGAgent = (
|
|||
/**
|
||||
* Message type for agent conversation
|
||||
*/
|
||||
export interface AgentMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
export type AgentMessage = { role: 'user'; content: string } | AgentHistoryMessage;
|
||||
|
||||
export interface AgentRuntimeOptions {
|
||||
/** Capture assistant/tool messages for providers that require exact transcript replay. */
|
||||
captureHistory?: boolean;
|
||||
}
|
||||
|
||||
export const buildLangChainMessages = (messages: AgentMessage[]): BaseMessage[] =>
|
||||
messages.map((message) => {
|
||||
if (message.role === 'user') {
|
||||
return new HumanMessage(message.content);
|
||||
}
|
||||
if (message.role === 'tool') {
|
||||
return new ToolMessage({
|
||||
content: message.content,
|
||||
tool_call_id: message.toolCallId,
|
||||
...(message.name ? { name: message.name } : {}),
|
||||
});
|
||||
}
|
||||
return new AIMessage({
|
||||
content: message.content,
|
||||
...(typeof message.reasoningContent === 'string'
|
||||
? { additional_kwargs: { reasoning_content: message.reasoningContent } }
|
||||
: {}),
|
||||
...(message.toolCalls?.length ? { tool_calls: message.toolCalls } : {}),
|
||||
} as any);
|
||||
});
|
||||
|
||||
export const serializeAgentHistoryMessages = (
|
||||
messages: unknown[],
|
||||
startIndex = 0,
|
||||
): AgentHistoryMessage[] => {
|
||||
const serialized: AgentHistoryMessage[] = [];
|
||||
for (const rawMessage of messages.slice(startIndex)) {
|
||||
const msg: any = rawMessage;
|
||||
const msgType = msg?._getType?.() || msg?.type || msg?.constructor?.name || 'unknown';
|
||||
if (msgType === 'ai' || msgType === 'AIMessage') {
|
||||
const reasoningContent = (msg.additional_kwargs || msg.kwargs)?.reasoning_content;
|
||||
const toolCalls = normalizeToolCalls(msg.tool_calls);
|
||||
serialized.push({
|
||||
role: 'assistant',
|
||||
content: normalizeMessageContent(msg.content),
|
||||
...(toolCalls?.length && typeof reasoningContent === 'string' ? { reasoningContent } : {}),
|
||||
...(toolCalls?.length ? { toolCalls } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (msgType === 'tool' || msgType === 'ToolMessage') {
|
||||
serialized.push({
|
||||
role: 'tool',
|
||||
content: normalizeMessageContent(msg.content),
|
||||
toolCallId: String(msg.tool_call_id ?? ''),
|
||||
...(typeof msg.name === 'string' ? { name: msg.name } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return serialized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream a response from the agent
|
||||
* Uses BOTH streamModes for best of both worlds:
|
||||
|
|
@ -340,12 +428,10 @@ export interface AgentMessage {
|
|||
export async function* streamAgentResponse(
|
||||
agent: ReturnType<typeof createReactAgent>,
|
||||
messages: AgentMessage[],
|
||||
options: AgentRuntimeOptions = {},
|
||||
): AsyncGenerator<AgentStreamChunk> {
|
||||
try {
|
||||
const formattedMessages = messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
const formattedMessages = buildLangChainMessages(messages);
|
||||
|
||||
// Use BOTH modes: 'values' for structure, 'messages' for token streaming
|
||||
const stream = await agent.stream({ messages: formattedMessages }, {
|
||||
|
|
@ -364,6 +450,9 @@ export async function* streamAgentResponse(
|
|||
// Anything before the first tool call should be treated as "reasoning/narration"
|
||||
// so the UI can show the Cursor-like loop: plan → tool → update → tool → answer.
|
||||
let hasSeenToolCallThisTurn = false;
|
||||
// Track the last set of messages so we can persist the raw assistant/tool
|
||||
// transcript for the next user turn.
|
||||
let lastStepMessages: any[] | null = null;
|
||||
|
||||
for await (const event of stream) {
|
||||
// Events come as [streamMode, data] tuples when using multiple modes
|
||||
|
|
@ -482,6 +571,9 @@ export async function* streamAgentResponse(
|
|||
// Handle 'values' mode - state snapshots for structure
|
||||
if (mode === 'values' && data?.messages) {
|
||||
const stepMessages = data.messages || [];
|
||||
if (options.captureHistory) {
|
||||
lastStepMessages = stepMessages;
|
||||
}
|
||||
|
||||
// Process new messages for tool calls/results we might have missed
|
||||
for (let i = lastProcessedMsgCount; i < stepMessages.length; i++) {
|
||||
|
|
@ -539,7 +631,14 @@ export async function* streamAgentResponse(
|
|||
if (import.meta.env.DEV) {
|
||||
console.log('✅ Stream completed normally, yielding done');
|
||||
}
|
||||
yield { type: 'done' };
|
||||
|
||||
yield {
|
||||
type: 'done',
|
||||
historyMessages:
|
||||
options.captureHistory && lastStepMessages
|
||||
? serializeAgentHistoryMessages(lastStepMessages, formattedMessages.length)
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
// DEBUG: Stream error
|
||||
|
|
@ -561,10 +660,7 @@ export const invokeAgent = async (
|
|||
agent: ReturnType<typeof createReactAgent>,
|
||||
messages: AgentMessage[],
|
||||
): Promise<string> => {
|
||||
const formattedMessages = messages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
const formattedMessages = buildLangChainMessages(messages);
|
||||
|
||||
const result = await agent.invoke({ messages: formattedMessages });
|
||||
|
||||
|
|
|
|||
257
gitnexus-web/src/core/llm/deepseek-chat-model.ts
Normal file
257
gitnexus-web/src/core/llm/deepseek-chat-model.ts
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
import {
|
||||
ChatOpenAI,
|
||||
ChatOpenAICompletions,
|
||||
type ChatOpenAICallOptions,
|
||||
type ChatOpenAICompletionsCallOptions,
|
||||
type ChatOpenAIFields,
|
||||
} from '@langchain/openai';
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import type { BaseLanguageModelInput } from '@langchain/core/language_models/base';
|
||||
import type { AIMessageChunk } from '@langchain/core/messages';
|
||||
import type { Runnable } from '@langchain/core/runnables';
|
||||
import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager';
|
||||
import type { ChatGenerationChunk, ChatResult } from '@langchain/core/outputs';
|
||||
import type { AgentToolCall } from './types';
|
||||
|
||||
/**
|
||||
* DeepSeek's thinking-mode chat API requires assistant `reasoning_content`
|
||||
* from prior turns to be replayed verbatim on the next request. LangChain
|
||||
* preserves the inbound value on `AIMessage.additional_kwargs`, but its
|
||||
* OpenAI-compatible outbound converter currently drops that provider-specific
|
||||
* field. This completions subclass keeps the behavior scoped to DeepSeek by
|
||||
* replacing only the serialized request messages immediately before the
|
||||
* DeepSeek API call.
|
||||
*/
|
||||
export class DeepSeekChatOpenAICompletions<
|
||||
CallOptions extends ChatOpenAICompletionsCallOptions = ChatOpenAICompletionsCallOptions,
|
||||
> extends ChatOpenAICompletions<CallOptions> {
|
||||
private activeMessages: BaseMessage[] | null = null;
|
||||
|
||||
private setActiveMessages(messages: BaseMessage[]): void {
|
||||
if (this.activeMessages !== null) {
|
||||
throw new Error('DeepSeekChatOpenAICompletions does not support overlapping requests');
|
||||
}
|
||||
this.activeMessages = messages;
|
||||
}
|
||||
|
||||
override async _generate(
|
||||
messages: BaseMessage[],
|
||||
options: this['ParsedCallOptions'],
|
||||
runManager?: CallbackManagerForLLMRun,
|
||||
): Promise<ChatResult> {
|
||||
this.setActiveMessages(messages);
|
||||
try {
|
||||
return await super._generate(messages, options, runManager);
|
||||
} finally {
|
||||
this.activeMessages = null;
|
||||
}
|
||||
}
|
||||
|
||||
override async *_streamResponseChunks(
|
||||
messages: BaseMessage[],
|
||||
options: this['ParsedCallOptions'],
|
||||
runManager?: CallbackManagerForLLMRun,
|
||||
): AsyncGenerator<ChatGenerationChunk> {
|
||||
this.setActiveMessages(messages);
|
||||
try {
|
||||
yield* super._streamResponseChunks(messages, options, runManager);
|
||||
} finally {
|
||||
this.activeMessages = null;
|
||||
}
|
||||
}
|
||||
|
||||
override async completionWithRetry(request: any, requestOptions?: any): Promise<any> {
|
||||
const messages = this.activeMessages
|
||||
? buildDeepSeekRequestMessages(this.activeMessages)
|
||||
: request.messages;
|
||||
return super.completionWithRetry({ ...request, messages }, requestOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAI-compatible DeepSeek chat model with a DeepSeek-specific completions
|
||||
* serializer. Keeping this as a subclass avoids provider checks in the shared
|
||||
* agent streaming path and ensures LangChain `withConfig()` clones used by tool
|
||||
* binding retain the same request serialization behavior.
|
||||
*/
|
||||
export class DeepSeekChatOpenAI<
|
||||
CallOptions extends ChatOpenAICallOptions = ChatOpenAICallOptions,
|
||||
> extends ChatOpenAI<CallOptions> {
|
||||
private readonly deepSeekFields: ChatOpenAIFields;
|
||||
|
||||
constructor(fields: ChatOpenAIFields) {
|
||||
const deepSeekFields = {
|
||||
...fields,
|
||||
completions: new DeepSeekChatOpenAICompletions(fields),
|
||||
} as ChatOpenAIFields;
|
||||
super(deepSeekFields);
|
||||
this.deepSeekFields = deepSeekFields;
|
||||
}
|
||||
|
||||
override withConfig(
|
||||
config: Partial<CallOptions>,
|
||||
): Runnable<BaseLanguageModelInput, AIMessageChunk, CallOptions> {
|
||||
// Mirror ChatOpenAI.withConfig() for this LangChain version, but keep the
|
||||
// DeepSeek subclass. Calling super.withConfig() would drop our custom
|
||||
// completions serializer by returning a plain ChatOpenAI instance.
|
||||
const newModel = new DeepSeekChatOpenAI<CallOptions>(this.deepSeekFields);
|
||||
newModel.defaultOptions = {
|
||||
...this.defaultOptions,
|
||||
...config,
|
||||
} as typeof this.defaultOptions;
|
||||
return newModel;
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizeMessageContent = (content: unknown): string => {
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.filter((block: any) => block?.type === 'text' || typeof block === 'string')
|
||||
.map((block: any) => (typeof block === 'string' ? block : block.text || ''))
|
||||
.join('');
|
||||
}
|
||||
if (content == null) return '';
|
||||
return String(content);
|
||||
};
|
||||
|
||||
const normalizeToolCallArgs = (toolCall: any): Record<string, unknown> => {
|
||||
if (toolCall?.args && typeof toolCall.args === 'object') {
|
||||
return toolCall.args as Record<string, unknown>;
|
||||
}
|
||||
try {
|
||||
return toolCall?.function?.arguments ? JSON.parse(toolCall.function.arguments) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeToolCalls = (toolCalls: unknown): AgentToolCall[] | undefined => {
|
||||
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return undefined;
|
||||
return toolCalls.map((toolCall: any) => ({
|
||||
id: typeof toolCall?.id === 'string' ? toolCall.id : undefined,
|
||||
name: toolCall?.name || toolCall?.function?.name || 'unknown',
|
||||
args: normalizeToolCallArgs(toolCall),
|
||||
type: typeof toolCall?.type === 'string' ? toolCall.type : 'tool_call',
|
||||
}));
|
||||
};
|
||||
|
||||
const stringifyToolArguments = (args: unknown): string => {
|
||||
if (typeof args === 'string') return args;
|
||||
try {
|
||||
return JSON.stringify(args ?? {});
|
||||
} catch {
|
||||
return '{}';
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeOpenAIContent = (content: unknown): string | Array<Record<string, unknown>> => {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) return normalizeMessageContent(content);
|
||||
|
||||
const blocks = content.flatMap((block: any) => {
|
||||
if (typeof block === 'string') {
|
||||
return [{ type: 'text', text: block }];
|
||||
}
|
||||
if (block?.type === 'text' && typeof block.text === 'string') {
|
||||
return [{ type: 'text', text: block.text }];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
if (blocks.length === 0) return '';
|
||||
if (blocks.length === 1) return blocks[0].text as string;
|
||||
return blocks;
|
||||
};
|
||||
|
||||
const getOpenAIRole = (message: any): string => {
|
||||
const messageType =
|
||||
message?._getType?.() || message?.type || message?.constructor?.name || 'unknown';
|
||||
if ((message.additional_kwargs || {}).__openai_role__ === 'developer') {
|
||||
return 'developer';
|
||||
}
|
||||
switch (messageType) {
|
||||
case 'human':
|
||||
case 'HumanMessage':
|
||||
return 'user';
|
||||
case 'ai':
|
||||
case 'AIMessage':
|
||||
return 'assistant';
|
||||
case 'system':
|
||||
case 'SystemMessage':
|
||||
return 'system';
|
||||
case 'tool':
|
||||
case 'ToolMessage':
|
||||
return 'tool';
|
||||
case 'function':
|
||||
case 'FunctionMessage':
|
||||
return 'function';
|
||||
default:
|
||||
return typeof message.role === 'string' ? message.role : 'user';
|
||||
}
|
||||
};
|
||||
|
||||
export const buildDeepSeekRequestMessages = (
|
||||
messages: Array<BaseMessage | Record<string, unknown>>,
|
||||
): Array<Record<string, unknown>> =>
|
||||
messages.map((message: any) => {
|
||||
const role = getOpenAIRole(message);
|
||||
const additionalKwargs =
|
||||
message.additional_kwargs && typeof message.additional_kwargs === 'object'
|
||||
? message.additional_kwargs
|
||||
: {};
|
||||
const requestMessage: Record<string, unknown> = {
|
||||
role,
|
||||
content: normalizeOpenAIContent(message.content),
|
||||
};
|
||||
|
||||
if (typeof message.name === 'string' && message.name.length > 0) {
|
||||
requestMessage.name = message.name;
|
||||
}
|
||||
if (role === 'assistant') {
|
||||
const toolCalls = Array.isArray(message.tool_calls)
|
||||
? message.tool_calls
|
||||
: Array.isArray(additionalKwargs.tool_calls)
|
||||
? additionalKwargs.tool_calls
|
||||
: undefined;
|
||||
if (toolCalls?.length) {
|
||||
requestMessage.tool_calls = toolCalls.map((toolCall: any) => {
|
||||
if (toolCall?.function) {
|
||||
return {
|
||||
id: toolCall.id,
|
||||
type: toolCall.type ?? 'function',
|
||||
function: {
|
||||
name: toolCall.function.name,
|
||||
arguments: stringifyToolArguments(toolCall.function.arguments),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: toolCall?.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: toolCall?.name ?? 'unknown',
|
||||
arguments: stringifyToolArguments(toolCall?.args),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
if (additionalKwargs.function_call != null) {
|
||||
requestMessage.function_call = additionalKwargs.function_call;
|
||||
}
|
||||
if (toolCalls?.length && typeof additionalKwargs.reasoning_content === 'string') {
|
||||
requestMessage.reasoning_content = additionalKwargs.reasoning_content;
|
||||
}
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
if (role === 'tool' && typeof message.tool_call_id === 'string') {
|
||||
requestMessage.tool_call_id = message.tool_call_id;
|
||||
}
|
||||
|
||||
if (role === 'function' && typeof message.name === 'string') {
|
||||
requestMessage.name = message.name;
|
||||
}
|
||||
|
||||
return requestMessage;
|
||||
});
|
||||
|
|
@ -17,6 +17,7 @@ import {
|
|||
OpenRouterConfig,
|
||||
MiniMaxConfig,
|
||||
GLMConfig,
|
||||
DeepSeekConfig,
|
||||
ProviderConfig,
|
||||
} from './types';
|
||||
import { DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OLLAMA_BASE_URL } from '../../config/ui-constants';
|
||||
|
|
@ -59,6 +60,10 @@ const mergeWithDefaults = (parsed?: Partial<LLMSettings> | null): LLMSettings =>
|
|||
...DEFAULT_LLM_SETTINGS.glm,
|
||||
...parsed?.glm,
|
||||
},
|
||||
deepseek: {
|
||||
...DEFAULT_LLM_SETTINGS.deepseek,
|
||||
...parsed?.deepseek,
|
||||
},
|
||||
});
|
||||
|
||||
const readSettings = (storage: Storage): Partial<LLMSettings> | null => {
|
||||
|
|
@ -144,7 +149,9 @@ export const updateProviderSettings = <T extends LLMProvider>(
|
|||
? Partial<Omit<MiniMaxConfig, 'provider'>>
|
||||
: T extends 'glm'
|
||||
? Partial<Omit<GLMConfig, 'provider'>>
|
||||
: never
|
||||
: T extends 'deepseek'
|
||||
? Partial<Omit<DeepSeekConfig, 'provider'>>
|
||||
: never
|
||||
>,
|
||||
): LLMSettings => {
|
||||
const current = loadSettings();
|
||||
|
|
@ -239,6 +246,17 @@ export const updateProviderSettings = <T extends LLMProvider>(
|
|||
saveSettings(updated);
|
||||
return updated;
|
||||
}
|
||||
case 'deepseek': {
|
||||
const updated: LLMSettings = {
|
||||
...current,
|
||||
deepseek: {
|
||||
...(current.deepseek ?? {}),
|
||||
...(updates as Partial<Omit<DeepSeekConfig, 'provider'>>),
|
||||
},
|
||||
};
|
||||
saveSettings(updated);
|
||||
return updated;
|
||||
}
|
||||
default: {
|
||||
// Should be unreachable due to T extends LLMProvider, but keep a safe fallback
|
||||
const updated: LLMSettings = { ...current };
|
||||
|
|
@ -316,6 +334,10 @@ const providerBuilders: Record<LLMProvider, ProviderBuilder> = {
|
|||
maxTokens: settings.glm.maxTokens,
|
||||
} as GLMConfig;
|
||||
},
|
||||
deepseek: (settings) => {
|
||||
if (!settings.deepseek?.apiKey) return null;
|
||||
return { provider: 'deepseek', ...settings.deepseek } as DeepSeekConfig;
|
||||
},
|
||||
};
|
||||
|
||||
export const getActiveProviderConfig = (): ProviderConfig | null => {
|
||||
|
|
@ -347,6 +369,24 @@ export const clearSettings = (): void => {
|
|||
}
|
||||
};
|
||||
|
||||
interface ProviderCapabilities {
|
||||
/** Provider requires hidden assistant/tool transcript replay across turns. */
|
||||
preserveAssistantTranscript: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PROVIDER_CAPABILITIES: ProviderCapabilities = {
|
||||
preserveAssistantTranscript: false,
|
||||
};
|
||||
|
||||
const PROVIDER_CAPABILITIES: Partial<Record<LLMProvider, ProviderCapabilities>> = {
|
||||
deepseek: { preserveAssistantTranscript: true },
|
||||
};
|
||||
|
||||
export const getProviderCapabilities = (provider: LLMProvider): ProviderCapabilities => ({
|
||||
...DEFAULT_PROVIDER_CAPABILITIES,
|
||||
...PROVIDER_CAPABILITIES[provider],
|
||||
});
|
||||
|
||||
/**
|
||||
* Get display name for a provider
|
||||
*/
|
||||
|
|
@ -368,6 +408,8 @@ export const getProviderDisplayName = (provider: LLMProvider): string => {
|
|||
return 'MiniMax';
|
||||
case 'glm':
|
||||
return 'GLM (Z.AI)';
|
||||
case 'deepseek':
|
||||
return 'DeepSeek';
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
|
|
@ -398,6 +440,8 @@ export const getAvailableModels = (provider: LLMProvider): string[] => {
|
|||
return ['MiniMax-M2.5', 'MiniMax-M2.5-highspeed'];
|
||||
case 'glm':
|
||||
return ['GLM-5', 'GLM-5-Turbo', 'GLM-4.7', 'GLM-4.5'];
|
||||
case 'deepseek':
|
||||
return ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* LLM Provider Types
|
||||
*
|
||||
* Type definitions for multi-provider LLM support.
|
||||
* Supports OpenAI, Azure OpenAI, Gemini, Anthropic, Ollama, OpenRouter, MiniMax, and GLM5.
|
||||
* Supports OpenAI, Azure OpenAI, Gemini, Anthropic, Ollama, OpenRouter, MiniMax, GLM, and DeepSeek.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -17,7 +17,8 @@ export type LLMProvider =
|
|||
| 'ollama'
|
||||
| 'openrouter'
|
||||
| 'minimax'
|
||||
| 'glm';
|
||||
| 'glm'
|
||||
| 'deepseek';
|
||||
|
||||
/**
|
||||
* Base configuration shared by all providers
|
||||
|
|
@ -106,6 +107,15 @@ export interface GLMConfig extends BaseProviderConfig {
|
|||
baseUrl?: string; // defaults to https://api.z.ai/api/coding/paas/v4
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek configuration — OpenAI-compatible API
|
||||
*/
|
||||
export interface DeepSeekConfig extends BaseProviderConfig {
|
||||
provider: 'deepseek';
|
||||
apiKey: string;
|
||||
model: string; // e.g., 'deepseek-v4-flash', 'deepseek-v4-pro'
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for all provider configurations
|
||||
*/
|
||||
|
|
@ -117,7 +127,8 @@ export type ProviderConfig =
|
|||
| OllamaConfig
|
||||
| OpenRouterConfig
|
||||
| MiniMaxConfig
|
||||
| GLMConfig;
|
||||
| GLMConfig
|
||||
| DeepSeekConfig;
|
||||
|
||||
/**
|
||||
* Stored settings (what goes to localStorage)
|
||||
|
|
@ -136,6 +147,7 @@ export interface LLMSettings {
|
|||
openrouter?: Partial<Omit<OpenRouterConfig, 'provider'>>;
|
||||
minimax?: Partial<Omit<MiniMaxConfig, 'provider'>>;
|
||||
glm?: Partial<Omit<GLMConfig, 'provider'>>;
|
||||
deepseek?: Partial<Omit<DeepSeekConfig, 'provider'>>;
|
||||
|
||||
// Intelligent Clustering Settings
|
||||
intelligentClustering: boolean;
|
||||
|
|
@ -197,6 +209,11 @@ export const DEFAULT_LLM_SETTINGS: LLMSettings = {
|
|||
baseUrl: 'https://api.z.ai/api/coding/paas/v4',
|
||||
temperature: 0.1,
|
||||
},
|
||||
deepseek: {
|
||||
apiKey: '',
|
||||
model: 'deepseek-v4-flash',
|
||||
temperature: 0.1,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -219,6 +236,8 @@ export interface ChatMessage {
|
|||
id: string;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
/** Hidden raw transcript for reconstructing future agent turns */
|
||||
historyMessages?: AgentHistoryMessage[];
|
||||
/** @deprecated Use steps instead for proper ordering */
|
||||
toolCalls?: ToolCallInfo[];
|
||||
/** Ordered steps: reasoning, tool calls, and final content interleaved */
|
||||
|
|
@ -238,6 +257,34 @@ export interface ToolCallInfo {
|
|||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal tool-call payload needed to reconstruct prior assistant turns.
|
||||
*/
|
||||
export interface AgentToolCall {
|
||||
id?: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
type: 'tool_call';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hidden per-turn transcript we keep so providers like DeepSeek can replay
|
||||
* the original assistant/tool exchange on later user turns.
|
||||
*/
|
||||
export type AgentHistoryMessage =
|
||||
| {
|
||||
role: 'assistant';
|
||||
content: string;
|
||||
reasoningContent?: string;
|
||||
toolCalls?: AgentToolCall[];
|
||||
}
|
||||
| {
|
||||
role: 'tool';
|
||||
content: string;
|
||||
toolCallId: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Streaming chunk from agent
|
||||
* Now supports step-based streaming where each step is a distinct message
|
||||
|
|
@ -248,6 +295,8 @@ export interface AgentStreamChunk {
|
|||
reasoning?: string;
|
||||
/** Final answer content (streamed token by token) */
|
||||
content?: string;
|
||||
/** Hidden raw transcript for reconstructing future agent turns */
|
||||
historyMessages?: AgentHistoryMessage[];
|
||||
/** Tool call information */
|
||||
toolCall?: ToolCallInfo;
|
||||
/** Error message */
|
||||
|
|
|
|||
|
|
@ -18,7 +18,12 @@ import type {
|
|||
ToolCallInfo,
|
||||
MessageStep,
|
||||
} from '../core/llm/types';
|
||||
import { loadSettings, getActiveProviderConfig, saveSettings } from '../core/llm/settings-service';
|
||||
import {
|
||||
loadSettings,
|
||||
getActiveProviderConfig,
|
||||
getProviderCapabilities,
|
||||
saveSettings,
|
||||
} from '../core/llm/settings-service';
|
||||
import type { AgentMessage } from '../core/llm/agent';
|
||||
import { type EdgeType } from '../lib/constants';
|
||||
import {
|
||||
|
|
@ -635,6 +640,8 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
|
||||
const sendChatMessage = useCallback(
|
||||
async (message: string): Promise<void> => {
|
||||
if (isChatLoading) return;
|
||||
|
||||
// Refresh Code panel for the new question: keep user-pinned refs, clear old AI citations
|
||||
clearAICodeReferences();
|
||||
// Also clear previous tool-driven AI highlights (highlight_in_graph)
|
||||
|
|
@ -674,11 +681,23 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
setIsChatLoading(true);
|
||||
setCurrentToolCalls([]);
|
||||
|
||||
const providerCapabilities = getProviderCapabilities(llmSettings.activeProvider);
|
||||
|
||||
// Prepare message history for agent (convert our format to AgentMessage format)
|
||||
const history: AgentMessage[] = [...chatMessages, userMessage].map((m) => ({
|
||||
role: m.role === 'tool' ? 'assistant' : m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
const history: AgentMessage[] = [...chatMessages, userMessage].flatMap<AgentMessage>((m) => {
|
||||
if (m.role === 'user') {
|
||||
return [{ role: 'user', content: m.content }];
|
||||
}
|
||||
if (m.role === 'tool') {
|
||||
return m.toolCallId
|
||||
? [{ role: 'tool', content: m.content, toolCallId: m.toolCallId }]
|
||||
: [];
|
||||
}
|
||||
if (providerCapabilities.preserveAssistantTranscript && m.historyMessages?.length) {
|
||||
return m.historyMessages;
|
||||
}
|
||||
return [{ role: 'assistant', content: m.content }];
|
||||
});
|
||||
|
||||
// Create placeholder for assistant response
|
||||
const assistantMessageId = `assistant-${Date.now()}`;
|
||||
|
|
@ -687,6 +706,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
// Keep toolCalls for backwards compat and currentToolCalls state
|
||||
const toolCallsForMessage: ToolCallInfo[] = [];
|
||||
let stepCounter = 0;
|
||||
let assistantHistoryMessages: ChatMessage['historyMessages'];
|
||||
|
||||
// Helper to update the message with current steps
|
||||
const updateMessage = () => {
|
||||
|
|
@ -703,6 +723,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
id: assistantMessageId,
|
||||
role: 'assistant' as const,
|
||||
content,
|
||||
historyMessages: assistantHistoryMessages,
|
||||
steps: [...stepsForMessage],
|
||||
toolCalls: [...toolCallsForMessage],
|
||||
timestamp: existing?.timestamp ?? Date.now(),
|
||||
|
|
@ -985,6 +1006,9 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
break;
|
||||
|
||||
case 'done':
|
||||
assistantHistoryMessages = providerCapabilities.preserveAssistantTranscript
|
||||
? chunk.historyMessages
|
||||
: undefined;
|
||||
// Finalize the assistant message - just call updateMessage one more time
|
||||
scheduleMessageUpdate();
|
||||
break;
|
||||
|
|
@ -996,10 +1020,11 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
const agent = agentRef.current;
|
||||
if (!agent) throw new Error('Agent not initialized');
|
||||
const { streamAgentResponse } = await import('../core/llm/agent');
|
||||
for await (const chunk of streamAgentResponse(agent, history)) {
|
||||
for await (const chunk of streamAgentResponse(agent, history, {
|
||||
captureHistory: providerCapabilities.preserveAssistantTranscript,
|
||||
})) {
|
||||
onChunk(chunk);
|
||||
}
|
||||
onChunk({ type: 'done' });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setAgentError(message);
|
||||
|
|
@ -1019,6 +1044,7 @@ const AppStateProviderInner = ({ children }: { children: ReactNode }) => {
|
|||
clearAIToolHighlights,
|
||||
graph,
|
||||
embeddingStatus,
|
||||
isChatLoading,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
|
|||
396
gitnexus-web/test/unit/agent-history.test.ts
Normal file
396
gitnexus-web/test/unit/agent-history.test.ts
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildLangChainMessages,
|
||||
createChatModel,
|
||||
serializeAgentHistoryMessages,
|
||||
type AgentMessage,
|
||||
} from '../../src/core/llm/agent';
|
||||
import {
|
||||
buildDeepSeekRequestMessages,
|
||||
DeepSeekChatOpenAI,
|
||||
DeepSeekChatOpenAICompletions,
|
||||
} from '../../src/core/llm/deepseek-chat-model';
|
||||
|
||||
describe('buildLangChainMessages', () => {
|
||||
it('reconstructs assistant tool-call turns for replay', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: 'user', content: 'Check the weather' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Let me check that.',
|
||||
reasoningContent: '',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call_weather',
|
||||
name: 'get_weather',
|
||||
args: { location: 'Hangzhou' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: 'Cloudy 7~13°C',
|
||||
toolCallId: 'call_weather',
|
||||
name: 'get_weather',
|
||||
},
|
||||
];
|
||||
|
||||
const langChainMessages = buildLangChainMessages(messages);
|
||||
|
||||
expect(langChainMessages).toHaveLength(3);
|
||||
expect((langChainMessages[1] as any).additional_kwargs.reasoning_content).toBe('');
|
||||
expect((langChainMessages[1] as any).tool_calls).toEqual([
|
||||
{
|
||||
id: 'call_weather',
|
||||
name: 'get_weather',
|
||||
args: { location: 'Hangzhou' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
]);
|
||||
expect((langChainMessages[2] as any).tool_call_id).toBe('call_weather');
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeAgentHistoryMessages', () => {
|
||||
it('captures assistant and tool messages from a completed turn', () => {
|
||||
const serialized = serializeAgentHistoryMessages(
|
||||
[
|
||||
{ _getType: () => 'human', content: 'old prompt' },
|
||||
{
|
||||
_getType: () => 'ai',
|
||||
content: 'Let me check that.',
|
||||
additional_kwargs: { reasoning_content: 'Need weather tool.' },
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_weather',
|
||||
name: 'get_weather',
|
||||
args: { location: 'Hangzhou' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
_getType: () => 'tool',
|
||||
content: 'Cloudy 7~13°C',
|
||||
tool_call_id: 'call_weather',
|
||||
name: 'get_weather',
|
||||
},
|
||||
{
|
||||
_getType: () => 'ai',
|
||||
content: 'Tomorrow will be cloudy.',
|
||||
additional_kwargs: { reasoning_content: 'Result received.' },
|
||||
},
|
||||
],
|
||||
1,
|
||||
);
|
||||
|
||||
expect(serialized).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Let me check that.',
|
||||
reasoningContent: 'Need weather tool.',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call_weather',
|
||||
name: 'get_weather',
|
||||
args: { location: 'Hangzhou' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: 'Cloudy 7~13°C',
|
||||
toolCallId: 'call_weather',
|
||||
name: 'get_weather',
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Tomorrow will be cloudy.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDeepSeekRequestMessages', () => {
|
||||
it('preserves reasoning_content on assistant tool-call messages', () => {
|
||||
const requestMessages = buildDeepSeekRequestMessages(
|
||||
buildLangChainMessages([
|
||||
{ role: 'user', content: '如何支持Gitlab Repo' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoningContent: 'I should inspect the repository support flow first.',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
name: 'search',
|
||||
args: { query: 'Gitlab repo support' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: 'No matches',
|
||||
toolCallId: 'call_1',
|
||||
name: 'search',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(requestMessages).toEqual([
|
||||
{ role: 'user', content: '如何支持Gitlab Repo' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoning_content: 'I should inspect the repository support flow first.',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'search',
|
||||
arguments: '{"query":"Gitlab repo support"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: 'No matches',
|
||||
name: 'search',
|
||||
tool_call_id: 'call_1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('drops reasoning_content from assistant messages without tool calls', () => {
|
||||
const messages = buildLangChainMessages([
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Hi there',
|
||||
reasoningContent: 'I should greet the user.',
|
||||
},
|
||||
]);
|
||||
|
||||
const requestMessages = buildDeepSeekRequestMessages(messages);
|
||||
|
||||
expect(requestMessages).toEqual([
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops reasoningContent from serialized assistant messages without tool calls', () => {
|
||||
const serialized = serializeAgentHistoryMessages(
|
||||
[
|
||||
{
|
||||
_getType: () => 'ai',
|
||||
content: 'Simple answer.',
|
||||
additional_kwargs: { reasoning_content: 'Thinking about it.' },
|
||||
},
|
||||
],
|
||||
0,
|
||||
);
|
||||
|
||||
expect(serialized).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Simple answer.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe('createChatModel', () => {
|
||||
it('keeps DeepSeek model subclasses on withConfig clones used for tool binding', () => {
|
||||
const model = createChatModel({
|
||||
provider: 'deepseek',
|
||||
apiKey: 'test-key',
|
||||
model: 'deepseek-v4-flash',
|
||||
temperature: 0.1,
|
||||
} as any) as any;
|
||||
|
||||
expect(model).toBeInstanceOf(DeepSeekChatOpenAI);
|
||||
expect(model.completions).toBeInstanceOf(DeepSeekChatOpenAICompletions);
|
||||
|
||||
const clonedModel = model.withConfig({ tools: [] }) as any;
|
||||
|
||||
expect(clonedModel).toBeInstanceOf(DeepSeekChatOpenAI);
|
||||
expect(clonedModel.completions).toBeInstanceOf(DeepSeekChatOpenAICompletions);
|
||||
});
|
||||
|
||||
it('uses DeepSeek serialization on withConfig clones', async () => {
|
||||
const model = createChatModel({
|
||||
provider: 'deepseek',
|
||||
apiKey: 'test-key',
|
||||
model: 'deepseek-v4-flash',
|
||||
temperature: 0.1,
|
||||
} as any) as any;
|
||||
const clonedModel = model.withConfig({ tools: [] }) as any;
|
||||
clonedModel.completions.streaming = false;
|
||||
let capturedRequest: any;
|
||||
|
||||
clonedModel.completions.client = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: async (request: any) => {
|
||||
capturedRequest = request;
|
||||
return {
|
||||
choices: [
|
||||
{
|
||||
message: { role: 'assistant', content: 'ok' },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await clonedModel.completions._generate(
|
||||
buildLangChainMessages([
|
||||
{ role: 'user', content: 'Check the weather' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoningContent: 'Need the weather tool.',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call_weather',
|
||||
name: 'get_weather',
|
||||
args: { location: 'Hangzhou' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'tool',
|
||||
content: 'Cloudy 7~13°C',
|
||||
toolCallId: 'call_weather',
|
||||
name: 'get_weather',
|
||||
},
|
||||
]),
|
||||
{ stream: false },
|
||||
);
|
||||
|
||||
expect(capturedRequest.messages[1].reasoning_content).toBe('Need the weather tool.');
|
||||
expect(capturedRequest.messages[1].tool_calls[0].function.arguments).toBe(
|
||||
'{"location":"Hangzhou"}',
|
||||
);
|
||||
expect(capturedRequest.messages[2].tool_call_id).toBe('call_weather');
|
||||
});
|
||||
|
||||
it('preserves reasoning_content through the streaming path used by DeepSeek tool calls', async () => {
|
||||
const model = createChatModel({
|
||||
provider: 'deepseek',
|
||||
apiKey: 'test-key',
|
||||
model: 'deepseek-v4-flash',
|
||||
temperature: 0.1,
|
||||
} as any) as any;
|
||||
model.completions.streaming = true;
|
||||
|
||||
async function* mockStream() {
|
||||
yield {
|
||||
id: 'chatcmpl-1',
|
||||
model: 'deepseek-v4-flash',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
role: 'assistant',
|
||||
reasoning_content: 'Need the weather tool.',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
yield {
|
||||
id: 'chatcmpl-1',
|
||||
model: 'deepseek-v4-flash',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: 'call_weather',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_weather',
|
||||
arguments: '{"location":"Hangzhou"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: 'tool_calls',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
model.completions.client = {
|
||||
chat: {
|
||||
completions: {
|
||||
create: async () => mockStream(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let streamedMessage: any;
|
||||
for await (const chunk of model.completions._streamResponseChunks(
|
||||
buildLangChainMessages([{ role: 'user', content: 'Check the weather' }]),
|
||||
{},
|
||||
)) {
|
||||
streamedMessage = streamedMessage ? streamedMessage.concat(chunk.message) : chunk.message;
|
||||
}
|
||||
|
||||
expect(streamedMessage.additional_kwargs.reasoning_content).toBe('Need the weather tool.');
|
||||
expect(streamedMessage.tool_calls).toEqual([
|
||||
{
|
||||
id: 'call_weather',
|
||||
name: 'get_weather',
|
||||
args: { location: 'Hangzhou' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(serializeAgentHistoryMessages([streamedMessage], 0)).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
reasoningContent: 'Need the weather tool.',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call_weather',
|
||||
name: 'get_weather',
|
||||
args: { location: 'Hangzhou' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects overlapping DeepSeek requests before reusing active messages', async () => {
|
||||
const model = createChatModel({
|
||||
provider: 'deepseek',
|
||||
apiKey: 'test-key',
|
||||
model: 'deepseek-v4-flash',
|
||||
temperature: 0.1,
|
||||
} as any) as any;
|
||||
|
||||
model.completions.activeMessages = buildLangChainMessages([{ role: 'user', content: 'busy' }]);
|
||||
|
||||
await expect(
|
||||
model.completions._generate(
|
||||
buildLangChainMessages([{ role: 'user', content: 'Check the weather' }]),
|
||||
{ stream: false },
|
||||
),
|
||||
).rejects.toThrow('DeepSeekChatOpenAICompletions does not support overlapping requests');
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
clearSettings,
|
||||
getProviderDisplayName,
|
||||
getAvailableModels,
|
||||
getProviderCapabilities,
|
||||
} from '../../src/core/llm/settings-service';
|
||||
|
||||
describe('loadSettings', () => {
|
||||
|
|
@ -104,6 +105,17 @@ describe('getActiveProviderConfig', () => {
|
|||
expect(config!.provider).toBe('openai');
|
||||
});
|
||||
|
||||
it('returns config for deepseek when API key is set', () => {
|
||||
const settings = loadSettings();
|
||||
settings.activeProvider = 'deepseek';
|
||||
settings.deepseek = { ...settings.deepseek, apiKey: 'sk-deepseek-123' };
|
||||
saveSettings(settings);
|
||||
|
||||
const config = getActiveProviderConfig();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.provider).toBe('deepseek');
|
||||
});
|
||||
|
||||
it('returns null for openrouter with empty API key', () => {
|
||||
const settings = loadSettings();
|
||||
settings.activeProvider = 'openrouter';
|
||||
|
|
@ -139,6 +151,7 @@ describe('getProviderDisplayName', () => {
|
|||
expect(getProviderDisplayName('anthropic')).toBe('Anthropic');
|
||||
expect(getProviderDisplayName('ollama')).toBe('Ollama (Local)');
|
||||
expect(getProviderDisplayName('openrouter')).toBe('OpenRouter');
|
||||
expect(getProviderDisplayName('deepseek')).toBe('DeepSeek');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -147,9 +160,18 @@ describe('getAvailableModels', () => {
|
|||
expect(getAvailableModels('openai').length).toBeGreaterThan(0);
|
||||
expect(getAvailableModels('ollama').length).toBeGreaterThan(0);
|
||||
expect(getAvailableModels('anthropic')).toContain('claude-sonnet-4-20250514');
|
||||
expect(getAvailableModels('deepseek')).toContain('deepseek-v4-flash');
|
||||
});
|
||||
|
||||
it('returns empty array for unknown provider', () => {
|
||||
expect(getAvailableModels('unknown' as any)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderCapabilities', () => {
|
||||
it('enables transcript replay only for providers that require it', () => {
|
||||
expect(getProviderCapabilities('deepseek').preserveAssistantTranscript).toBe(true);
|
||||
expect(getProviderCapabilities('openai').preserveAssistantTranscript).toBe(false);
|
||||
expect(getProviderCapabilities('anthropic').preserveAssistantTranscript).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue