Skip to content

Commit a4a411a

Browse files
authored
feat: OpenAI 兼容层工具调用增强 (tool_choice / parallel_tool_calls / strict) (#6)
* feat(openai): add tool_choice and parallel_tool_calls to request model - Add tool_choice and parallel_tool_calls fields to ChatCompletionRequest - Implement _preprocess_tools() for tool filtering based on tool_choice - Update convert_messages_to_prompt() signature with new params - Update route.py call site to pass new parameters - Add degradation logging for tool_choice mismatches * feat(openai): add JSON Schema block, Strict Mode paragraph, and dynamic REMINDER assembly * tests(openai): add TDD tests for tool_choice, parallel_tool_calls, and strict mode * docs: document tool_choice, parallel_tool_calls, and strict in OpenAI adapter README * smoke(openai): add smoke tests for tool_choice, parallel_tool_calls, and strict mode * fix(logging): move security warnings to FastAPI startup event to avoid duplicate logs in reload mode
1 parent 32640fe commit a4a411a

8 files changed

Lines changed: 601 additions & 15 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,12 @@ OpenAI-compatible chat completions endpoint with full tool calling support and s
118118
| `messages` | array | OpenAI-style message array |
119119
| `stream` | bool | Streaming response, default `false` |
120120
| `tools` | array | Tool definitions for function calling |
121+
| `tool_choice` | string \| object | Controls which tools the model may call. Values: `"auto"` (default), `"none"` (disable tools), `"required"` (must call at least one tool), or `{"type": "function", "function": {"name": "..."}}` (call a specific tool). This parameter is proxy-layer only and not forwarded to DeepSeek. |
122+
| `parallel_tool_calls` | bool | Whether to allow parallel tool calls. Default `true`. When `false`, the model is instructed to call only one tool at a time. This parameter is proxy-layer only and not forwarded to DeepSeek. |
121123
| `extra_body` | dict | DeepSeek-specific parameters (see below) |
122124

125+
> **Note on `tools`**: Each tool supports a `strict` property inside `function` (e.g., `{"type": "function", "function": {"name": "...", "strict": true}}`). When `strict: true`, the model is instructed to strictly follow the JSON Schema — do not add undefined fields, do not omit required fields, do not use values outside enum lists. Both natural language description and JSON Schema block are included in the prompt for maximum constraint fidelity.
126+
123127
**DeepSeek-specific parameters via `extra_body`**:
124128

125129
| Parameter | Type | Default | Description |

README.中文.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,12 @@ OpenAI兼容的对话完成接口,完全支持工具调用和流式响应。
118118
| `messages` | array | OpenAI 风格消息数组 |
119119
| `stream` | bool | 流式响应,默认为 `false` |
120120
| `tools` | array | 函数调用工具定义 |
121+
| `tool_choice` | string \| object | 控制模型可以调用哪些工具。值:`"auto"`(默认)、`"none"`(禁用工具)、`"required"`(必须至少调用一个工具)、或 `{"type": "function", "function": {"name": "..."}}`(调用指定工具)。此参数仅在代理层使用,不会转发给 DeepSeek。 |
122+
| `parallel_tool_calls` | bool | 是否允许并行工具调用。默认为 `true`。设为 `false` 时,模型将被指示每次只调用一个工具。此参数仅在代理层使用,不会转发给 DeepSeek。 |
121123
| `extra_body` | dict | DeepSeek 特有参数(见下方) |
122124

125+
> **关于 `tools` 的说明**:每个工具在 `function` 对象内支持 `strict` 属性(如 `{"type": "function", "function": {"name": "...", "strict": true}}`)。当 `strict: true` 时,模型将被指示严格遵循 JSON Schema——不添加未定义的字段、不省略必填字段、不使用枚举列表以外的值。提示中会同时包含自然语言描述和 JSON Schema 代码块,以获得最大的约束保真度。
126+
123127
**通过 `extra_body` 传递 DeepSeek 特有参数**
124128

125129
| 参数 | 类型 | 默认值 | 说明 |

src/deepseek_web_api/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@
33
from .api.routes import app
44
from .api.openai import models_router, chat_completions_router
55
from .core.logger import setup_logger
6-
from .core.server_security import log_startup_security_warnings
76

87
# Setup centralized logger (level configured in config.toml or default WARNING)
98
setup_logger()
10-
log_startup_security_warnings()
119

1210
# Include OpenAI compatible routers
1311
app.include_router(models_router)

src/deepseek_web_api/api/openai/chat_completions/messages.py

Lines changed: 99 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,60 @@
11
"""Message conversion utilities for OpenAI-style messages to DeepSeek prompt."""
22

33
import json
4+
import logging
45
from typing import List, Optional, Union
56

67
from .tools import TOOL_START_MARKER, TOOL_END_MARKER
78

9+
logger = logging.getLogger(__name__)
10+
11+
12+
def _preprocess_tools(tools: Optional[List[dict]], tool_choice: Union[str, dict]) -> tuple[Optional[List[dict]], dict]:
13+
"""Preprocess tools based on tool_choice.
14+
15+
Returns: (effective_tools, tool_choice_info)
16+
- effective_tools: filtered list, or None if tools should not be exposed
17+
- tool_choice_info: dict with keys:
18+
- degraded: bool
19+
- reason: str (None, "no_tools_available", "tool_not_found", "invalid_value")
20+
- missing_name: str (only if reason == "tool_not_found")
21+
"""
22+
info: dict = {"degraded": False, "reason": None, "missing_name": None}
23+
24+
# Handle "none" - tools explicitly disabled
25+
if tool_choice == "none":
26+
return None, info
27+
28+
# Handle "auto" or "required" - use all tools as-is
29+
if tool_choice == "auto":
30+
return tools, info
31+
32+
if tool_choice == "required":
33+
if not tools:
34+
info["degraded"] = True
35+
info["reason"] = "no_tools_available"
36+
return tools if tools else None, info
37+
38+
# Handle specific tool selection via dict
39+
if isinstance(tool_choice, dict):
40+
func_spec = tool_choice.get("function")
41+
if func_spec and isinstance(func_spec, dict):
42+
name = func_spec.get("name")
43+
if name and tools:
44+
for t in tools:
45+
if t.get("function", {}).get("name") == name:
46+
return [t], info
47+
# Tool not found
48+
info["degraded"] = True
49+
info["reason"] = "tool_not_found"
50+
info["missing_name"] = name
51+
return None, info
52+
53+
# Invalid tool_choice value - degrade but preserve original tools
54+
info["degraded"] = True
55+
info["reason"] = "invalid_value"
56+
return tools, info
57+
858

959
def extract_text_content(content: Union[str, List, None]) -> str:
1060
"""Extract plain text from OpenAI message content field.
@@ -27,19 +77,36 @@ def extract_text_content(content: Union[str, List, None]) -> str:
2777
return ""
2878

2979

30-
def convert_messages_to_prompt(messages: List[dict], tools: Optional[List[dict]] = None) -> str:
80+
def convert_messages_to_prompt(
81+
messages: List[dict],
82+
tools: Optional[List[dict]] = None,
83+
tool_choice: Union[str, dict] = "auto",
84+
parallel_tool_calls: bool = True,
85+
) -> str:
3186
"""Convert OpenAI-style messages array to DeepSeek prompt format.
3287
3388
Args:
3489
messages: List of OpenAI-style messages with role and content
3590
tools: Optional OpenAI tools specification
91+
tool_choice: Controls which tools the model may call (default "auto")
92+
parallel_tool_calls: Whether to allow parallel tool calls (default True)
3693
3794
Returns:
3895
Formatted prompt string for DeepSeek API
3996
"""
4097
prompt_parts = []
4198
system_parts = []
4299

100+
# Preprocess tools based on tool_choice
101+
effective_tools, tool_choice_info = _preprocess_tools(tools, tool_choice)
102+
103+
# Log degradation if any
104+
if tool_choice_info["degraded"]:
105+
logger.warning(
106+
f"[messages] tool_choice degraded: reason={tool_choice_info['reason']}, "
107+
f"missing_name={tool_choice_info.get('missing_name')}"
108+
)
109+
43110
for msg in messages:
44111
role = msg.get("role", "")
45112
content = msg.get("content")
@@ -75,9 +142,9 @@ def convert_messages_to_prompt(messages: List[dict], tools: Optional[List[dict]]
75142
prompt_parts.append(f"\nTool: id={tool_id}\n```\n{text}\n```")
76143

77144
# Inject tools into system instruction
78-
if tools:
145+
if effective_tools:
79146
tools_lines = []
80-
for t in tools:
147+
for t in effective_tools:
81148
func = t.get('function', {})
82149
name = func.get('name')
83150
desc = func.get('description') or ''
@@ -103,7 +170,15 @@ def convert_messages_to_prompt(messages: List[dict], tools: Optional[List[dict]]
103170
if not params.get('additionalProperties', True):
104171
param_desc += "\n Note: Additional parameters are not allowed."
105172

106-
tools_lines.append(f"- {name}: {desc}{param_desc}")
173+
schema_json = ""
174+
if params:
175+
schema_json = "\n```json\n" + json.dumps(params, ensure_ascii=False) + "\n```\n"
176+
177+
strict_notice = ""
178+
if func.get('strict', False):
179+
strict_notice = "\n**Strict Mode**: Function calls MUST exactly match the specified schema. Do NOT add fields not defined, do not omit required fields, do not use values outside enum list."
180+
181+
tools_lines.append(f"- {name}: {desc}{schema_json}{param_desc}{strict_notice}")
107182

108183
tools_prompt = "## Available Tools\n" + "\n".join(tools_lines)
109184
tools_prompt += """
@@ -127,8 +202,25 @@ def convert_messages_to_prompt(messages: List[dict], tools: Optional[List[dict]]
127202
if system_parts:
128203
prompt_parts.insert(0, "[System Instruction]\n" + "\n---\n".join(system_parts) + "\n---")
129204

130-
# Add separator and REMINDER before Assistant output if tools are available
131-
if tools:
132-
prompt_parts.append("\n---\nAbove is our conversation history.\n\n[REMINDER] When you need to call tools, you MUST use the [TOOL🛠️]...[/TOOL🛠️] tags. For multiple tool calls, wrap them in a JSON array: [TOOL🛠️][{\"name\": \"func1\", \"arguments\": {...}}, {\"name\": \"func2\", \"arguments\": {...}}][/TOOL🛠️].")
205+
# Add separator and REMINDER before Assistant output
206+
if effective_tools is not None:
207+
reminder_parts = ["When you need to call tools, you MUST use the [TOOL🛠️]...[/TOOL🛠️] tags."]
208+
209+
if tool_choice == "required":
210+
reminder_parts.append("**You MUST call at least one tool before responding.**")
211+
212+
if not parallel_tool_calls and not (tool_choice_info["degraded"] and tool_choice_info["reason"] == "tool_not_found"):
213+
reminder_parts.append("**Call only ONE tool at a time.**")
214+
215+
reminder_text = " ".join(reminder_parts)
216+
prompt_parts.append(f"\n---\nAbove is our conversation history.\n\n[REMINDER] {reminder_text}")
217+
218+
elif effective_tools is None and tool_choice_info["degraded"]:
219+
reason = tool_choice_info["reason"]
220+
if reason == "no_tools_available":
221+
prompt_parts.append("\n---\nAbove is our conversation history.\n\n[REMINDER] The requested tool operation requires available tools, but none were provided. Inform the user that no tools are available.")
222+
elif reason == "tool_not_found":
223+
missing = tool_choice_info.get("missing_name", "the requested tool")
224+
prompt_parts.append(f"\n---\nAbove is our conversation history.\n\n[REMINDER] The requested tool \"{missing}\" is not available. Inform the user that this tool is not available.")
133225

134226
return "\n\n".join(prompt_parts)

src/deepseek_web_api/api/openai/chat_completions/route.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import json
55
import time
66
import uuid
7-
from typing import List, Optional
7+
from typing import List, Optional, Union
88

99
from fastapi import APIRouter, Request, HTTPException
1010
from fastapi.responses import StreamingResponse, JSONResponse
@@ -34,11 +34,17 @@ class ChatCompletionRequest(BaseModel):
3434
"thinking_enabled": False,
3535
}
3636
)
37+
38+
Proxy-layer parameters (not forwarded to DeepSeek):
39+
- tool_choice: Controls which tools the model may call (default "auto")
40+
- parallel_tool_calls: Whether to allow parallel tool calls (default True)
3741
"""
3842
model: str = "deepseek-web-chat"
3943
messages: List[dict]
4044
stream: bool = False
4145
tools: Optional[List[dict]] = None
46+
tool_choice: Optional[Union[str, dict]] = "auto"
47+
parallel_tool_calls: Optional[bool] = True
4248
extra_body: Optional[dict] = None
4349

4450

@@ -71,7 +77,12 @@ async def chat_completions(request: Request):
7177

7278
validated = ChatCompletionRequest(**data)
7379
logger.debug(f"Request payload: {json.dumps(data, ensure_ascii=False, indent=2)}")
74-
prompt = convert_messages_to_prompt(validated.messages, validated.tools)
80+
prompt = convert_messages_to_prompt(
81+
validated.messages,
82+
validated.tools,
83+
validated.tool_choice,
84+
validated.parallel_tool_calls,
85+
)
7586
logger.debug(f"Constructed prompt:\n{prompt}")
7687

7788
# Extract DeepSeek-specific parameters from extra_body

src/deepseek_web_api/api/routes.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from fastapi import FastAPI, HTTPException, Request, Response
44
from fastapi.middleware.cors import CORSMiddleware
5-
from fastapi.responses import JSONResponse
5+
from fastapi.responses import JSONResponse, StreamingResponse
66

77
from ..core.config import (
88
get_cors_allow_credentials,
@@ -11,9 +11,9 @@
1111
get_cors_origin_regex,
1212
get_cors_origins,
1313
)
14-
from ..core.logger import logger
1514
from ..core.local_api_auth import requires_local_api_auth, verify_local_api_auth
16-
from fastapi.responses import StreamingResponse
15+
from ..core.logger import logger
16+
from ..core.server_security import log_startup_security_warnings
1717

1818
from .v0_service import (
1919
stream_chat_completion,
@@ -45,6 +45,12 @@ def get_cors_middleware_options() -> dict:
4545
app.add_middleware(CORSMiddleware, **get_cors_middleware_options())
4646

4747

48+
@app.on_event("startup")
49+
async def startup_security_warnings():
50+
"""Log security warnings once when the server worker starts."""
51+
log_startup_security_warnings()
52+
53+
4854
@app.middleware("http")
4955
async def local_api_auth_middleware(request: Request, call_next):
5056
"""Protect /v0 and /v1 endpoints with optional local API key auth."""

0 commit comments

Comments
 (0)