mirror of
https://github.com/JackChen-me/open-multi-agent.git
synced 2026-10-23 11:33:52 -05:00
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:
14 files changed
+587
-46
No files matched your search
+1
-1
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user