Merge remote-tracking branch 'upstream/main' into feat/reasoning-text-fallback

# Conflicts:
#	src/llm/openai-common.ts
#	src/llm/openai.ts
This commit is contained in:
MyPrototypeWhat committed 2026-05-27 14:42:16 +08:00
commit 9ca9a9180f
14 files changed
+587 -46

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@ The framework ships a wired-in provider name for each of these. Set `provider` a
| Azure OpenAI | `provider: 'azure-openai'` | `AZURE_OPENAI_API_KEY`, `AZURE_OPENAI_ENDPOINT` | `gpt-4` | Optional `AZURE_OPENAI_API_VERSION`, `AZURE_OPENAI_DEPLOYMENT`. |
| GitHub Copilot | `provider: 'copilot'` | `GITHUB_COPILOT_TOKEN` (falls back to `GITHUB_TOKEN`) | `gpt-4o` | Custom token-exchange flow on top of OpenAI protocol. |
| Grok (xAI) | `provider: 'grok'` | `XAI_API_KEY` | `grok-4` | OpenAI-compatible; endpoint is `api.x.ai/v1`. |
| DeepSeek | `provider: 'deepseek'` | `DEEPSEEK_API_KEY` | `deepseek-chat` | OpenAI-compatible. `deepseek-chat` (V3, coding) or `deepseek-reasoner` (thinking mode). |
| DeepSeek | `provider: 'deepseek'` | `DEEPSEEK_API_KEY` | `deepseek-v4-flash` | OpenAI-compatible. `deepseek-v4-flash` (default) or `deepseek-v4-pro` (flagship for coding); both support 1M context and 384K max output. Legacy `deepseek-chat` / `deepseek-reasoner` retire 2026-07-24. |
| MiniMax (global) | `provider: 'minimax'` | `MINIMAX_API_KEY` | `MiniMax-M2.7` | OpenAI-compatible. |
| MiniMax (China) | `provider: 'minimax'` + `MINIMAX_BASE_URL` | `MINIMAX_API_KEY` | `MiniMax-M2.7` | Set `MINIMAX_BASE_URL=https://api.minimaxi.com/v1`. |
| Qiniu | `provider: 'qiniu'` | `QINIU_API_KEY` | `deepseek-v3` | OpenAI-compatible. Endpoint `https://api.qnaigc.com/v1`; multiple model families, see [Qiniu AI docs](https://developer.qiniu.com/aitokenapi/12882/ai-inference-api). |
+2 -1
View File
@@ -2,7 +2,7 @@
Runnable scripts demonstrating `open-multi-agent`. Organized by category — pick one that matches what you're trying to do.
All scripts run with `npx tsx examples/<category>/<name>.ts` and require the corresponding API key in your environment.
All scripts run with `npx tsx examples/<category>/<name>.ts`. Scripts that call a model require the corresponding API key in your environment.
---
@@ -71,6 +71,7 @@ Hooking the framework up to outside-the-box tooling.
|---------|-----------------|
| [`integrations/trace-observability`](integrations/trace-observability.ts) | `onTrace` spans for LLM calls, tools, and tasks. |
| [`integrations/mcp-github`](integrations/mcp-github.ts) | An MCP server's tools exposed to an agent via `connectMCPTools()`. |
| [`integrations/mcp-bilig-workpaper`](integrations/mcp-bilig-workpaper.ts) | Bilig WorkPaper MCP tools for formula readback, recalculation, and persisted workbook JSON. |
| [`integrations/with-vercel-ai-sdk/`](integrations/with-vercel-ai-sdk/) | Next.js app — OMA `runTeam()` + AI SDK `useChat` streaming. |
| [`integrations/express-customer-support/`](integrations/express-customer-support/) | Express REST API — `runTasks()` behind POST /tickets with per-agent Zod output schemas, mix-and-match provider env vars, and HTTP error mapping (400/502/504). |
@@ -0,0 +1,84 @@
/**
* MCP Bilig WorkPaper Tools
*
* Connect Bilig's file-backed WorkPaper MCP server over stdio and give an
* open-multi-agent Agent the workbook tools for formula readback.
*
* Run:
* npx tsx examples/integrations/mcp-bilig-workpaper.ts
*
* Prerequisites:
* - GEMINI_API_KEY or GOOGLE_API_KEY
* - @modelcontextprotocol/sdk installed
* - npm can execute @bilig/workpaper@0.96.0 from the public registry
*/
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Agent, ToolExecutor, ToolRegistry, registerBuiltInTools } from '../../src/index.js'
import { connectMCPTools } from '../../src/mcp.js'
if (!process.env.GEMINI_API_KEY?.trim() && !process.env.GOOGLE_API_KEY?.trim()) {
console.error('Missing GEMINI_API_KEY or GOOGLE_API_KEY: set a Gemini API key in the environment.')
process.exit(1)
}
const biligPackage = '@bilig/workpaper@0.96.0'
const workDir = await mkdtemp(join(tmpdir(), 'oma-bilig-workpaper-'))
const workpaperPath = join(workDir, 'pricing.workpaper.json')
const { tools, disconnect } = await connectMCPTools({
command: 'npm',
args: [
'exec',
'--yes',
'--package',
biligPackage,
'--',
'bilig-workpaper-mcp',
'--workpaper',
workpaperPath,
'--init-demo-workpaper',
'--writable',
],
namePrefix: 'bilig',
requestTimeoutMs: 60_000,
})
const registry = new ToolRegistry()
registerBuiltInTools(registry)
for (const tool of tools) registry.register(tool)
const executor = new ToolExecutor(registry)
const agent = new Agent(
{
name: 'bilig-workpaper-agent',
model: process.env.AGENT_MODEL ?? 'gemini-2.5-flash',
provider: 'gemini',
maxTurns: 8,
parallelToolCalls: false,
tools: tools.map((tool) => tool.name),
systemPrompt: [
'Use Bilig WorkPaper MCP tools to inspect and edit formula workbooks.',
'Always verify a write by reading the recalculated output cell afterward.',
'Keep the final answer short and include the before and after values.',
].join(' '),
},
registry,
executor,
)
try {
const result = await agent.run(
[
'Use the Bilig MCP tools on the demo pricing workbook.',
'List sheets, read Summary!B3, set Inputs!B3 to 0.4, then read Summary!B3 again.',
'Report whether the workbook recalculated and persisted the edit.',
].join(' '),
)
console.log(result.output)
} finally {
await disconnect()
}
@@ -33,7 +33,7 @@
* AGENT_PROVIDER=grok AGENT_MODEL=grok-3 XAI_API_KEY=... ENGRAM_INVITE_KEY=ek_live_... npx tsx examples/integrations/with-engram/research-team.ts
*
* # DeepSeek
* AGENT_PROVIDER=deepseek AGENT_MODEL=deepseek-chat DEEPSEEK_API_KEY=... ENGRAM_INVITE_KEY=ek_live_... npx tsx examples/integrations/with-engram/research-team.ts
* AGENT_PROVIDER=deepseek AGENT_MODEL=deepseek-v4-flash DEEPSEEK_API_KEY=... ENGRAM_INVITE_KEY=ek_live_... npx tsx examples/integrations/with-engram/research-team.ts
*
* Prerequisites:
* - API key env var for your chosen provider
@@ -7,7 +7,7 @@ export const maxDuration = 120
// --- DeepSeek via OpenAI-compatible API ---
const DEEPSEEK_BASE_URL = 'https://api.deepseek.com'
const DEEPSEEK_MODEL = 'deepseek-chat'
const DEEPSEEK_MODEL = 'deepseek-v4-flash'
const deepseek = createOpenAICompatible({
name: 'deepseek',
+7 -7
View File
@@ -11,19 +11,19 @@
* DEEPSEEK_API_KEY environment variable must be set.
*
* Available models:
* deepseek-chat — DeepSeek-V3 (non-thinking mode, recommended for coding tasks)
* deepseek-reasoner — DeepSeek-V3 (thinking mode, for complex reasoning)
* deepseek-v4-flash — economical (1M context)
* deepseek-v4-pro — flagship, best for coding (1M context)
*/
import { OpenMultiAgent } from '../../src/index.js'
import type { AgentConfig, OrchestratorEvent } from '../../src/types.js'
// ---------------------------------------------------------------------------
// Agent definitions (all using deepseek-chat)
// Agent definitions
// ---------------------------------------------------------------------------
const architect: AgentConfig = {
name: 'architect',
model: 'deepseek-reasoner',
model: 'deepseek-v4-pro',
provider: 'deepseek',
systemPrompt: `You are a software architect with deep experience in Node.js and REST API design.
Your job is to design clear, production-quality API contracts and file/directory structures.
@@ -35,7 +35,7 @@ Output concise plans in markdown — no unnecessary prose.`,
const developer: AgentConfig = {
name: 'developer',
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
provider: 'deepseek',
systemPrompt: `You are a TypeScript/Node.js developer. You implement what the architect specifies.
Write clean, runnable code with proper error handling. Use the tools to write files and run tests.`,
@@ -46,7 +46,7 @@ Write clean, runnable code with proper error handling. Use the tools to write fi
const reviewer: AgentConfig = {
name: 'reviewer',
model: 'deepseek-chat',
model: 'deepseek-v4-flash',
provider: 'deepseek',
systemPrompt: `You are a senior code reviewer. Review code for correctness, security, and clarity.
Provide a structured review with: LGTM items, suggestions, and any blocking issues.
@@ -93,7 +93,7 @@ function handleProgress(event: OrchestratorEvent): void {
// Orchestrate
// ---------------------------------------------------------------------------
const orchestrator = new OpenMultiAgent({
defaultModel: 'deepseek-chat',
defaultModel: 'deepseek-v4-flash',
defaultProvider: 'deepseek',
maxConcurrency: 1, // sequential for readable output
onProgress: handleProgress,
+2 -2
View File
@@ -54,7 +54,7 @@ const PROVIDER_REFERENCE: ReadonlyArray<{
{ id: 'gemini', apiKeyEnv: ['GEMINI_API_KEY', 'GOOGLE_API_KEY'], baseUrlSupported: false },
{ id: 'grok', apiKeyEnv: ['XAI_API_KEY'], baseUrlSupported: true },
{ id: 'minimax', apiKeyEnv: ['MINIMAX_API_KEY'], baseUrlSupported: true, notes: 'Global endpoint: https://api.minimax.io/v1 (default). China endpoint: https://api.minimaxi.com/v1. Set MINIMAX_BASE_URL to choose, or pass baseURL in agent config.' },
{ id: 'deepseek', apiKeyEnv: ['DEEPSEEK_API_KEY'], baseUrlSupported: true, notes: 'OpenAI-compatible endpoint at https://api.deepseek.com/v1. Models: deepseek-chat (V3), deepseek-reasoner (thinking).' },
{ id: 'deepseek', apiKeyEnv: ['DEEPSEEK_API_KEY'], baseUrlSupported: true, notes: 'OpenAI-compatible endpoint at https://api.deepseek.com/v1. Models: deepseek-v4-flash (default), deepseek-v4-pro (flagship); both support 1M context. Legacy deepseek-chat/deepseek-reasoner retire 2026-07-24.' },
{ id: 'qiniu', apiKeyEnv: ['QINIU_API_KEY'], baseUrlSupported: true, notes: 'OpenAI-compatible endpoint at https://api.qnaigc.com/v1. Set provider to qiniu and choose a model available to your key.' },
{
id: 'copilot',
@@ -287,7 +287,7 @@ const DEFAULT_MODEL_HINT: Record<SupportedProvider, string> = {
grok: 'grok-2-latest',
copilot: 'gpt-4o',
minimax: 'MiniMax-M2.7',
deepseek: 'deepseek-chat',
deepseek: 'deepseek-v4-flash',
qiniu: 'deepseek-v3',
bedrock: 'anthropic.claude-3-5-haiku-20241022-v1:0',
}
+22 -2
View File
@@ -8,17 +8,37 @@
import { OpenAIAdapter } from './openai.js'
/**
* LLM adapter for DeepSeek models (deepseek-chat, deepseek-reasoner, and future models).
* LLM adapter for DeepSeek V4 models. Both models support a 1M context window.
*
* Thread-safe. Can be shared across agents.
*
* Usage:
* provider: 'deepseek'
* model: 'deepseek-chat' (or 'deepseek-reasoner' for the thinking model)
* model: 'deepseek-v4-flash' (economical) or 'deepseek-v4-pro' (flagship)
*
* Legacy `deepseek-chat` and `deepseek-reasoner` map to the non-thinking and
* thinking modes of `deepseek-v4-flash` respectively, and will be fully retired
* by DeepSeek on 2026-07-24.
*/
export class DeepSeekAdapter extends OpenAIAdapter {
readonly name = 'deepseek'
// DeepSeek V4 in thinking mode requires `reasoning_content` to be echoed
// back on EVERY intermediate assistant message of a tool-calling
// conversation, including the final synthesis message that has no
// `tool_calls` of its own. Omitting any of them 400s on the next user
// turn. See:
// https://api-docs.deepseek.com/zh-cn/guides/thinking_mode
// The `'tool-use-only'` capability tells the OpenAIAdapter base class to
// pass `nativeReasoningEchoProvider: 'deepseek'` to the message builder,
// which attaches `reasoning_content` on every assistant message in a
// tool-calling conversation that carries a deepseek-provenance reasoning
// block. Non-tool conversations drop reasoning entirely (the spec says
// it is ignored there but would still bloat context).
override readonly capabilities = {
echoesReasoning: 'tool-use-only' as const,
}
constructor(apiKey?: string, baseURL?: string) {
// Allow override of baseURL (for proxies or future changes) but default to official DeepSeek endpoint.
super(
+64 -9
View File
@@ -105,9 +105,22 @@ export function toOpenAIMessages(
): ChatCompletionMessageParam[] {
const result: ChatCompletionMessageParam[] = []
// Per DeepSeek V4 thinking-mode spec, when a conversation involves any
// tool call, ALL intermediate assistant messages must echo
// `reasoning_content` — not just the one that emitted the tool_use.
// Omitting reasoning on the final synthesis turn (tool_calls=None) 400s
// on the next user message. We approximate "tool-calling conversation"
// as "any tool_use anywhere in history"; non-tool conversations skip
// the echo entirely (per spec, reasoning would be ignored but still
// bloat context). See:
// https://api-docs.deepseek.com/zh-cn/guides/thinking_mode
const conversationHasToolUse = messages.some((m) =>
m.content.some((b) => b.type === 'tool_use'),
)
for (const msg of messages) {
if (msg.role === 'assistant') {
const assistantMsg = toOpenAIAssistantMessage(msg, outboundOptions)
const assistantMsg = toOpenAIAssistantMessage(msg, outboundOptions, conversationHasToolUse)
if (assistantMsg !== null) {
result.push(assistantMsg)
}
@@ -175,12 +188,20 @@ function toOpenAIUserMessage(msg: LLMMessage): ChatCompletionUserMessageParam {
function toOpenAIAssistantMessage(
msg: LLMMessage,
outboundOptions?: ReasoningOutboundOptions,
conversationHasToolUse = false,
): ChatCompletionAssistantMessageParam | null {
const toolCalls: ChatCompletionMessageToolCall[] = []
const textParts: string[] = []
const pendingThinkingParts: string[] = []
const echoProvider = outboundOptions?.nativeReasoningEchoProvider
const enableReasoningReplay = outboundOptions?.preserveReasoningAsText === true
const resolvedMaxChars = resolveReasoningOutboundMaxChars(outboundOptions)
// Collected only when an `echoProvider` is configured. Emitted as a
// `reasoning_content` field on the assistant payload below, gated by
// `conversationHasToolUse` (DeepSeek V4 rule: applies to every assistant
// message in a tool-calling conversation, including the final synthesis
// message that has no tool_calls of its own).
const echoEligibleReasoning: string[] = []
for (const block of msg.content) {
if (block.type === 'tool_use') {
@@ -192,14 +213,33 @@ function toOpenAIAssistantMessage(
arguments: JSON.stringify(block.input),
},
})
} else if (block.type === 'reasoning' && enableReasoningReplay) {
// OpenAI-family adapters are uniformly capability `'never'` — every
// reasoning block falls back to text regardless of provenance.
const serialized = resolvedMaxChars === undefined
? reasoningBlockToInlineText(block)
: reasoningBlockToInlineText(block, { maxChars: resolvedMaxChars })
if (serialized.length > 0) {
pendingThinkingParts.push(serialized)
} else if (block.type === 'reasoning') {
// Path A: native echo (adapter wired with nativeReasoningEchoProvider
// and the block's provenance matches). The block is queued for
// attachment as `reasoning_content`; gating by tool_use happens after
// the loop so we don't emit it on non-tool turns.
if (echoProvider !== undefined && block.provenance === echoProvider) {
const isRedacted = typeof block.redactedData === 'string' && block.redactedData.length > 0
if (!isRedacted && block.text.length > 0) {
echoEligibleReasoning.push(block.text)
}
// Either way, don't double-emit via the text-replay path below.
continue
}
// Path B: `<thinking>` text fallback for foreign-provenance blocks
// (or any block when this adapter doesn't support native echo) when
// `preserveReasoningAsText` is on. OpenAI-family adapters are
// capability `'never'` by default, so every reasoning block hits this
// branch in plain OpenAI use; DeepSeek (`'tool-use-only'`) hits this
// only for foreign-provenance blocks since its own-provenance ones
// are claimed by Path A above.
if (enableReasoningReplay) {
const serialized = resolvedMaxChars === undefined
? reasoningBlockToInlineText(block)
: reasoningBlockToInlineText(block, { maxChars: resolvedMaxChars })
if (serialized.length > 0) {
pendingThinkingParts.push(serialized)
}
}
} else if (block.type === 'text') {
if (pendingThinkingParts.length > 0) {
@@ -228,6 +268,21 @@ function toOpenAIAssistantMessage(
assistantMsg.tool_calls = toolCalls
}
// DeepSeek V4 (thinking mode) returns 400 on follow-up requests if any
// intermediate assistant message in a tool-calling conversation drops
// `reasoning_content` — including the final synthesis message that has
// no tool_calls of its own. The gate is on the whole conversation, not
// this single message. Non-tool conversations skip the attachment (per
// spec, reasoning is ignored there but would still bloat context).
//
// The field is not declared on the upstream SDK type, so we attach it
// via an indexed cast; the SDK serialises arbitrary own properties.
if (conversationHasToolUse && echoEligibleReasoning.length > 0) {
const reasoningContent = echoEligibleReasoning.join('')
;(assistantMsg as ChatCompletionAssistantMessageParam & { reasoning_content?: string })
.reasoning_content = reasoningContent
}
return assistantMsg
}
+47 -10
View File
@@ -57,6 +57,7 @@ import {
buildOpenAIMessageList,
getOpenAIReasoningText,
} from './openai-common.js'
import type { ReasoningOutboundOptions } from './reasoning-fallback.js'
import { extractToolCallsFromText } from '../tool/text-tool-extractor.js'
// ---------------------------------------------------------------------------
@@ -71,14 +72,20 @@ import { extractToolCallsFromText } from '../tool/text-tool-extractor.js'
export class OpenAIAdapter implements LLMAdapter {
readonly name: string = 'openai'
readonly capabilities = {
// OpenAI Chat Completions does not accept `reasoning_content` on input.
// When the user sets {@link AgentConfig.preserveReasoningAsText}, the
// outbound conversion in `toOpenAIMessages` (openai-common.ts) downgrades
// each reasoning block to inline `<thinking>` text via the shared
// helper in `reasoning-fallback.ts`. Without the opt-in, reasoning
// blocks are dropped silently.
echoesReasoning: 'never' as const,
// The field type is intentionally widened to the full union (rather than
// narrowed to `'never'` via `as const`) so subclasses can override with a
// different value — DeepSeek currently uses `'tool-use-only'` for native
// `reasoning_content` echo on tool-use turns (per PR #251).
//
// OpenAI itself stays `'never'`: Chat Completions does not accept
// `reasoning_content` on input under any circumstance. When the user sets
// `AgentConfig.preserveReasoningAsText` (PR #260 / #223 Phase 2), the
// outbound conversion in `toOpenAIMessages` downgrades each reasoning
// block to inline `<thinking>` text via the shared helper in
// `reasoning-fallback.ts`. Without the opt-in, reasoning blocks are
// dropped silently on outbound.
readonly capabilities: { readonly echoesReasoning: 'never' | 'own-issued' | 'tool-use-only' } = {
echoesReasoning: 'never',
}
readonly #client: OpenAI
@@ -90,6 +97,36 @@ export class OpenAIAdapter implements LLMAdapter {
})
}
/**
* Build the per-call options forwarded to {@link buildOpenAIMessageList}.
*
* Composes two orthogonal mechanisms:
* 1. **`nativeReasoningEchoProvider`** — set when this adapter's
* capability is `'tool-use-only'` (DeepSeek). Triggers native
* `reasoning_content` echo on assistant messages whose reasoning
* blocks carry matching provenance AND the conversation contains
* `tool_use`. See PR #251 / DeepSeek V4 thinking-mode spec.
* 2. **`preserveReasoningAsText` / `compressReasoningText`** — opt-in
* `<thinking>` text fallback from the user's `AgentConfig`. Applies
* to foreign-provenance reasoning AND to all reasoning on `'never'`
* adapters. See #223 Phase 2.
*
* The two paths are mutually exclusive per block: native echo claims
* own-provenance blocks first; text fallback claims everything else.
* Subclasses inherit `chat()` / `stream()` and so automatically pick up
* both behaviours; no subclass override needed.
*/
protected buildMessageOptions(options: LLMChatOptions): ReasoningOutboundOptions | undefined {
const wantsNativeEcho = this.capabilities.echoesReasoning === 'tool-use-only'
const wantsTextFallback = options.preserveReasoningAsText === true
if (!wantsNativeEcho && !wantsTextFallback) return undefined
return {
preserveReasoningAsText: options.preserveReasoningAsText,
compressReasoningText: options.compressReasoningText,
nativeReasoningEchoProvider: wantsNativeEcho ? this.name : undefined,
}
}
// -------------------------------------------------------------------------
// chat()
// -------------------------------------------------------------------------
@@ -102,7 +139,7 @@ export class OpenAIAdapter implements LLMAdapter {
* handle these (e.g. rate limits, context length exceeded).
*/
async chat(messages: LLMMessage[], options: LLMChatOptions): Promise<LLMResponse> {
const openAIMessages = buildOpenAIMessageList(messages, options.systemPrompt, { preserveReasoningAsText: options.preserveReasoningAsText, compressReasoningText: options.compressReasoningText })
const openAIMessages = buildOpenAIMessageList(messages, options.systemPrompt, this.buildMessageOptions(options))
const completion = await this.#client.chat.completions.create(
{
@@ -154,7 +191,7 @@ export class OpenAIAdapter implements LLMAdapter {
messages: LLMMessage[],
options: LLMStreamOptions,
): AsyncIterable<StreamEvent> {
const openAIMessages = buildOpenAIMessageList(messages, options.systemPrompt, { preserveReasoningAsText: options.preserveReasoningAsText, compressReasoningText: options.compressReasoningText })
const openAIMessages = buildOpenAIMessageList(messages, options.systemPrompt, this.buildMessageOptions(options))
// We request usage in the final chunk so we can include it in the `done` event.
const streamResponse = await this.#client.chat.completions.create(
+9 -1
View File
@@ -1152,11 +1152,19 @@ export interface LLMAdapter {
* unsigned thought summaries to keep context lean). Phase 2
* implementers MUST consult the adapter-specific outbound serializer
* for the full eligibility rule, not just the provenance match.
* - `'tool-use-only'`: Native echo is attempted on every assistant
* message inside a tool-calling conversation (one that contains at
* least one `tool_use` block anywhere in its history), gated by
* `block.provenance === this.name`. This includes the final synthesis
* message that has no `tool_use` of its own — omitting it would 400
* the next user turn. Conversations with no `tool_use` anywhere skip
* the echo entirely (per spec, reasoning is ignored there but would
* still bloat context). Used by DeepSeek V4 in thinking mode.
*
* Omitted on third-party adapters that pre-date this field; callers treat
* `undefined` as `'never'` to preserve today's silent-drop behaviour.
*/
readonly capabilities?: {
readonly echoesReasoning: 'never' | 'own-issued'
readonly echoesReasoning: 'never' | 'own-issued' | 'tool-use-only'
}
}
+319 -2
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { chatOpts, textMsg } from './helpers/llm-fixtures.js'
import { chatOpts, textMsg, toolDef } from './helpers/llm-fixtures.js'
import type { LLMMessage } from '../src/types.js'
// ---------------------------------------------------------------------------
// Mock OpenAI constructor (must be hoisted for Vitest)
@@ -91,7 +92,7 @@ describe('DeepSeekAdapter', () => {
it('stamps provenance: "deepseek" (not parent "openai") on extracted ReasoningBlocks', async () => {
createCompletionMock.mockResolvedValue({
id: 'chatcmpl-ds',
model: 'deepseek-reasoner',
model: 'deepseek-v4-pro',
choices: [{
index: 0,
message: {
@@ -114,4 +115,320 @@ describe('DeepSeekAdapter', () => {
provenance: 'deepseek',
})
})
// ---------------------------------------------------------------------------
// reasoning_content passback (DeepSeek V4 thinking mode)
//
// Per https://api-docs.deepseek.com/zh-cn/guides/thinking_mode, follow-up
// requests that include a prior tool-use assistant turn MUST echo
// `reasoning_content` back. Omitting it returns 400.
// ---------------------------------------------------------------------------
describe('reasoning_content echo on tool-use turns', () => {
function getAssistantMessage(callIndex = 0): Record<string, unknown> {
const call = createCompletionMock.mock.calls[callIndex]
if (call === undefined) throw new Error(`no mock call at index ${callIndex}`)
const messages = call[0].messages as Array<Record<string, unknown>>
const assistant = messages.find((m) => m['role'] === 'assistant')
if (assistant === undefined) throw new Error('no assistant message found in request')
return assistant
}
it('echoes reasoning_content on assistant messages that contain tool_calls', async () => {
// The agent runner's typical flow: turn 1 yields reasoning + tool_use;
// tools execute; turn 2 sends back the assistant + tool_result and the
// model returns its final answer.
createCompletionMock.mockResolvedValueOnce({
id: 'chatcmpl-ds-2',
model: 'deepseek-v4-pro',
choices: [{
index: 0,
message: { role: 'assistant', content: 'Answer.', tool_calls: undefined },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 5, completion_tokens: 3 },
})
const messages: LLMMessage[] = [
textMsg('user', 'Search for foo'),
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'I will use the search tool.', provenance: 'deepseek' },
{ type: 'tool_use', id: 'call_1', name: 'search', input: { q: 'foo' } },
],
},
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'call_1', content: '[results]' }],
},
]
const adapter = new DeepSeekAdapter('deepseek-key')
await adapter.chat(messages, chatOpts({ tools: [toolDef('search')] }))
const assistant = getAssistantMessage()
expect(assistant['tool_calls']).toBeDefined()
expect(assistant['reasoning_content']).toBe('I will use the search tool.')
})
it('does NOT echo reasoning_content in conversations with no tool_use anywhere (pure text dialog)', async () => {
// Per spec, `reasoning_content` on non-tool conversations is ignored
// by the API. Echoing it would just bloat context.
createCompletionMock.mockResolvedValueOnce({
id: 'chatcmpl-ds-3',
model: 'deepseek-v4-pro',
choices: [{
index: 0,
message: { role: 'assistant', content: 'Sure.', tool_calls: undefined },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 5, completion_tokens: 3 },
})
const messages: LLMMessage[] = [
textMsg('user', 'Hi'),
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'Just acknowledge.', provenance: 'deepseek' },
{ type: 'text', text: 'Hello!' },
],
},
textMsg('user', 'Now respond again.'),
]
const adapter = new DeepSeekAdapter('deepseek-key')
await adapter.chat(messages, chatOpts())
const assistant = getAssistantMessage()
expect(assistant['tool_calls']).toBeUndefined()
expect(assistant['reasoning_content']).toBeUndefined()
expect(assistant['content']).toBe('Hello!')
})
it('echoes reasoning_content on text-only assistant messages within a tool-calling conversation', async () => {
// This is the spec-critical case the original fix missed.
// Sequence:
// user → assistant#1[reasoning + tool_use] → tool_result →
// assistant#2[reasoning + text final] → user followup
// assistant#2 has NO tool_calls of its own, but it is still inside a
// tool-calling conversation and so its reasoning_content must be
// echoed too. Dropping it 400s on the next user turn.
createCompletionMock.mockResolvedValueOnce({
id: 'chatcmpl-ds-7',
model: 'deepseek-v4-pro',
choices: [{
index: 0,
message: { role: 'assistant', content: 'Bar follow-up.', tool_calls: undefined },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 5, completion_tokens: 3 },
})
const messages: LLMMessage[] = [
textMsg('user', 'Search for foo'),
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'Need to search.', provenance: 'deepseek' },
{ type: 'tool_use', id: 'call_1', name: 'search', input: { q: 'foo' } },
],
},
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'call_1', content: '[results]' }],
},
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'Synthesise the answer.', provenance: 'deepseek' },
{ type: 'text', text: 'Found foo at example.com' },
],
},
textMsg('user', 'Now what about bar?'),
]
const adapter = new DeepSeekAdapter('deepseek-key')
await adapter.chat(messages, chatOpts({ tools: [toolDef('search')] }))
const sentMessages = createCompletionMock.mock.calls[0][0].messages as Array<Record<string, unknown>>
const assistants = sentMessages.filter((m) => m['role'] === 'assistant')
expect(assistants).toHaveLength(2)
// assistant#1: tool_use + reasoning, echoed.
expect(assistants[0]['tool_calls']).toBeDefined()
expect(assistants[0]['reasoning_content']).toBe('Need to search.')
// assistant#2: text-only (no tool_calls) but in a tool-calling
// conversation — reasoning MUST still be echoed. This is the
// regression the original PR shipped with.
expect(assistants[1]['tool_calls']).toBeUndefined()
expect(assistants[1]['content']).toBe('Found foo at example.com')
expect(assistants[1]['reasoning_content']).toBe('Synthesise the answer.')
})
it('does NOT echo reasoning blocks from a foreign provenance', async () => {
// Cross-provider reasoning (e.g. an upstream Anthropic block carried
// through to a DeepSeek adapter) is not eligible for native echo —
// DeepSeek did not produce it and would not recognise its signature.
createCompletionMock.mockResolvedValueOnce({
id: 'chatcmpl-ds-4',
model: 'deepseek-v4-pro',
choices: [{
index: 0,
message: { role: 'assistant', content: 'Done.', tool_calls: undefined },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 5, completion_tokens: 3 },
})
const messages: LLMMessage[] = [
textMsg('user', 'Search for bar'),
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'Foreign reasoning.', provenance: 'anthropic' },
{ type: 'tool_use', id: 'call_1', name: 'search', input: { q: 'bar' } },
],
},
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'call_1', content: '[results]' }],
},
]
const adapter = new DeepSeekAdapter('deepseek-key')
await adapter.chat(messages, chatOpts({ tools: [toolDef('search')] }))
const assistant = getAssistantMessage()
expect(assistant['tool_calls']).toBeDefined()
expect(assistant['reasoning_content']).toBeUndefined()
})
it('drops a reasoning block with no provenance (treated as foreign)', async () => {
// Older clients or third-party IR constructors may produce reasoning
// blocks without `provenance`. Treat the same as foreign — silent drop.
createCompletionMock.mockResolvedValueOnce({
id: 'chatcmpl-ds-5',
model: 'deepseek-v4-pro',
choices: [{
index: 0,
message: { role: 'assistant', content: 'OK.', tool_calls: undefined },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 5, completion_tokens: 3 },
})
const messages: LLMMessage[] = [
textMsg('user', 'Search'),
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'No provenance.' },
{ type: 'tool_use', id: 'call_2', name: 'search', input: { q: 'x' } },
],
},
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'call_2', content: '[]' }],
},
]
const adapter = new DeepSeekAdapter('deepseek-key')
await adapter.chat(messages, chatOpts({ tools: [toolDef('search')] }))
const assistant = getAssistantMessage()
expect(assistant['reasoning_content']).toBeUndefined()
})
it('preserves reasoning_content across multiple historical tool-use turns', async () => {
// Each prior tool-use turn carries its own reasoning. The spec requires
// every such turn to keep its reasoning_content on subsequent requests,
// not just the most recent one.
createCompletionMock.mockResolvedValueOnce({
id: 'chatcmpl-ds-6',
model: 'deepseek-v4-pro',
choices: [{
index: 0,
message: { role: 'assistant', content: 'Final.', tool_calls: undefined },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 5, completion_tokens: 3 },
})
const messages: LLMMessage[] = [
textMsg('user', 'Multi-step research.'),
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'Plan A.', provenance: 'deepseek' },
{ type: 'tool_use', id: 'call_1', name: 'search', input: { q: 'a' } },
],
},
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'call_1', content: '[a]' }],
},
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'Plan B.', provenance: 'deepseek' },
{ type: 'tool_use', id: 'call_2', name: 'search', input: { q: 'b' } },
],
},
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'call_2', content: '[b]' }],
},
]
const adapter = new DeepSeekAdapter('deepseek-key')
await adapter.chat(messages, chatOpts({ tools: [toolDef('search')] }))
const sentMessages = createCompletionMock.mock.calls[0][0].messages as Array<Record<string, unknown>>
const assistants = sentMessages.filter((m) => m['role'] === 'assistant')
expect(assistants).toHaveLength(2)
expect(assistants[0]['reasoning_content']).toBe('Plan A.')
expect(assistants[1]['reasoning_content']).toBe('Plan B.')
})
it('does NOT echo reasoning_content from OpenAIAdapter (capability remains `never`)', async () => {
// Verifies the capability gate, not just the DeepSeek subclass:
// OpenAIAdapter itself must NOT attach reasoning_content even when
// matching-provenance reasoning is present in the message history.
const { OpenAIAdapter } = await import('../src/llm/openai.js')
createCompletionMock.mockResolvedValueOnce({
id: 'chatcmpl-oai',
model: 'gpt-4o',
choices: [{
index: 0,
message: { role: 'assistant', content: 'Final.', tool_calls: undefined },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 5, completion_tokens: 3 },
})
const messages: LLMMessage[] = [
textMsg('user', 'Search'),
{
role: 'assistant',
content: [
{ type: 'reasoning', text: 'thinking', provenance: 'openai' },
{ type: 'tool_use', id: 'call_1', name: 'search', input: { q: 'x' } },
],
},
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'call_1', content: '[]' }],
},
]
const openaiAdapter = new OpenAIAdapter('openai-key')
await openaiAdapter.chat(messages, chatOpts({ tools: [toolDef('search')] }))
const sentMessages = createCompletionMock.mock.calls[0][0].messages as Array<Record<string, unknown>>
const assistant = sentMessages.find((m) => m['role'] === 'assistant')
expect(assistant!['reasoning_content']).toBeUndefined()
})
})
})
+18 -5
View File
@@ -58,7 +58,7 @@ describe('LLMAdapter Phase 1 capability contract', () => {
['openai', () => new OpenAIAdapter('dummy-key'), 'never' as const],
['azure-openai', () => new AzureOpenAIAdapter('dummy-key', 'https://example.openai.azure.com'), 'never' as const],
['copilot', () => new CopilotAdapter('dummy-token'), 'never' as const],
['deepseek', () => new DeepSeekAdapter('dummy-key'), 'never' as const],
['deepseek', () => new DeepSeekAdapter('dummy-key'), 'tool-use-only' as const],
['grok', () => new GrokAdapter('dummy-key'), 'never' as const],
['qiniu', () => new QiniuAdapter('dummy-key'), 'never' as const],
['minimax', () => new MiniMaxAdapter('dummy-key'), 'never' as const],
@@ -70,15 +70,28 @@ describe('LLMAdapter Phase 1 capability contract', () => {
expect(adapter.capabilities?.echoesReasoning).toBe(expectedEcho)
})
it('OpenAI subclasses inherit `capabilities` from OpenAIAdapter', () => {
// Sanity check: the subclasses don't redeclare `capabilities` and rely on
// inheritance so any future adjustment to the parent's value propagates.
it('OpenAI subclasses inherit `capabilities` from OpenAIAdapter (except DeepSeek)', () => {
// Sanity check: most subclasses don't redeclare `capabilities` and rely
// on inheritance so any future adjustment to the parent's value
// propagates. DeepSeek is the deliberate exception — see the next test.
const parent = new OpenAIAdapter('dummy-key').capabilities
expect(new DeepSeekAdapter('dummy-key').capabilities).toEqual(parent)
expect(new GrokAdapter('dummy-key').capabilities).toEqual(parent)
expect(new QiniuAdapter('dummy-key').capabilities).toEqual(parent)
expect(new MiniMaxAdapter('dummy-key').capabilities).toEqual(parent)
})
it('DeepSeek overrides `capabilities` to `tool-use-only`', () => {
// DeepSeek V4 thinking mode requires `reasoning_content` to be echoed
// back on follow-up requests with prior tool-use turns; without this
// override DeepSeek would inherit OpenAI's `'never'` and hit 400 on the
// second turn of any tool-using agent. See deepseek.ts capability docs.
expect(new DeepSeekAdapter('dummy-key').capabilities).toEqual({
echoesReasoning: 'tool-use-only',
})
expect(new DeepSeekAdapter('dummy-key').capabilities).not.toEqual(
new OpenAIAdapter('dummy-key').capabilities,
)
})
})
describe('fromOpenAICompletion provenance stamping (Phase 1, #223)', () => {
+10 -4
View File
@@ -221,7 +221,7 @@ describe('OpenAIAdapter', () => {
])
})
it('passes tool names for fallback text extraction', async () => {
it('extracts fallback text tool calls using the requested tool names', async () => {
// When native tool_calls is empty but text contains tool JSON, the adapter
// should invoke extractToolCallsFromText with known tool names.
// We test this indirectly: the completion has text containing tool JSON
@@ -243,9 +243,15 @@ describe('OpenAIAdapter', () => {
chatOpts({ tools: [toolDef('search')] }),
)
// The fromOpenAICompletion + extractToolCallsFromText pipeline should find the tool
const toolBlocks = result.content.filter(b => b.type === 'tool_use')
expect(toolBlocks.length).toBeGreaterThanOrEqual(0) // may or may not extract depending on format
const toolBlocks = result.content.filter((b): b is ToolUseBlock => b.type === 'tool_use')
expect(toolBlocks).toHaveLength(1)
expect(toolBlocks[0]).toEqual({
type: 'tool_use',
id: expect.any(String),
name: 'search',
input: { q: 'test' },
})
expect(result.stop_reason).toBe('tool_use')
})
it('propagates SDK errors', async () => {