fix(provider): 文本协议 - 将系统指令写入上游请求并避免重复发送

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ddCat
2026-09-09 09:46:13 +08:00
parent e582d423bd
commit 6abd2a7e0e
5 changed files with 69 additions and 3 deletions

View File

@@ -963,7 +963,13 @@ func manifestRequestValues(request GenerationRequest) map[string]any {
userContent = append(userContent, map[string]any{"type": "audio_url", "audio_url": map[string]any{"url": val}})
}
}
messages := make([]any, 0, len(request.Messages)+1)
messages := make([]any, 0, len(request.Messages)+2)
// instructions 是统一的系统指令字段,但多数 OpenAI 系协议只映射 request.messages。
// 系统指令必须进入消息数组,否则会被静默丢弃;带独立 system 字段的协议(Claude、
// Gemini、Responses)在各自模板里过滤 system 角色,不会重复发送。
if instructions := strings.TrimSpace(request.Instructions); instructions != "" && !hasSystemMessage(request.Messages) {
messages = append(messages, map[string]any{"role": "system", "content": instructions})
}
for _, message := range request.Messages {
if strings.TrimSpace(message.Role) == "" || message.Content == nil {
continue
@@ -1028,6 +1034,15 @@ func manifestRequestValues(request GenerationRequest) map[string]any {
}
}
func hasSystemMessage(messages []Message) bool {
for _, message := range messages {
if strings.EqualFold(strings.TrimSpace(message.Role), "system") {
return true
}
}
return false
}
func requestAsManifestValue(value any) (any, error) {
encoded, err := json.Marshal(value)
if err != nil {

View File

@@ -480,6 +480,55 @@ func TestOfficialOpenAIVideosDeclaresAuthenticatedResultDownload(t *testing.T) {
}
}
// 系统指令必须真正到达上游:只映射 messages 的协议要收到 system 消息,
// 有独立 system 字段的协议要用该字段且不能在消息数组里重复发送。
func TestOfficialTextProtocolsDeliverInstructions(t *testing.T) {
tests := []struct {
name, packageName, providerID, messagesField, instructionField string
}{
{name: "openai-chat", packageName: "openai-chat-completions.yingce-plugin", providerID: "chat-completion", messagesField: "messages"},
{name: "deepseek", packageName: "deepseek-chat.yingce-plugin", providerID: "deepseek-chat", messagesField: "messages"},
{name: "atlascloud", packageName: "atlascloud-chat.yingce-plugin", providerID: "atlascloud-chat", messagesField: "messages"},
{name: "openai-responses", packageName: "openai-responses.yingce-plugin", providerID: "openai-response", messagesField: "input", instructionField: "instructions"},
{name: "anthropic", packageName: "anthropic-messages.yingce-plugin", providerID: "claude-api", messagesField: "messages", instructionField: "system"},
{name: "gemini", packageName: "google-gemini-generate-content.yingce-plugin", providerID: "gemini-generate-content", messagesField: "contents", instructionField: "systemInstruction"},
}
const instructions = "只输出一个 JSON 对象"
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
adapter := officialPackageAdapter(t, test.packageName, test.providerID)
spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{
Capability: CapabilityText, Model: "model-test", Prompt: "一句话故事", Instructions: instructions,
}})
if err != nil {
t.Fatal(err)
}
body := manifestTestBody(t, spec)
encodedMessages, err := json.Marshal(body[test.messagesField])
if err != nil {
t.Fatal(err)
}
carriesInstructions := strings.Contains(string(encodedMessages), instructions)
if test.instructionField == "" {
if !carriesInstructions {
t.Fatalf("%s dropped the system instruction: %s", test.providerID, encodedMessages)
}
return
}
if carriesInstructions {
t.Fatalf("%s duplicated the system instruction into %s: %s", test.providerID, test.messagesField, encodedMessages)
}
encodedInstruction, err := json.Marshal(body[test.instructionField])
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(encodedInstruction), instructions) {
t.Fatalf("%s did not map the system instruction to %s: %s", test.providerID, test.instructionField, encodedInstruction)
}
})
}
}
func officialPackageAdapter(t *testing.T, packageName, providerID string) Adapter {
t.Helper()
data, err := os.ReadFile(filepath.Join("..", "..", "..", "plugin-packages", packageName))

View File

@@ -112,7 +112,9 @@ add({
id: "openai-responses", providerId: "openai-response", name: "OpenAI Responses", vendor: "OpenAI", capability: "text",
baseUrl: "https://api.openai.com", auth: bearer, params: textParams,
create: jsonCreate("/responses", {
model: ref("request.model"), input: ref("request.messages"), instructions: omit(ref("request.instructions")),
model: ref("request.model"),
input: filter(ref("request.messages"), "message", ne(ref("message.role"), "system")),
instructions: omit(ref("request.instructions")),
temperature: omit(ref("request.providerOptions.openai-response.temperature")),
top_p: omit(ref("request.providerOptions.openai-response.top_p")),
max_output_tokens: omit(coalesce(ref("request.extra.max_output_tokens"), ref("request.providerOptions.openai-response.max_output_tokens"))),
@@ -156,7 +158,7 @@ add({
id: "google-gemini-generate-content", providerId: "gemini-generate-content", name: "Google Gemini generateContent", vendor: "Google", capability: "text",
baseUrl: "https://generativelanguage.googleapis.com", auth: { type: "google-api-key", field: "apiKey" }, params: textParams,
create: jsonCreate("/v1beta/models/{{model}}:generateContent", {
contents: map(ref("request.messages"), "message", {
contents: map(filter(ref("request.messages"), "message", ne(ref("message.role"), "system")), "message", {
role: conditional(eq(ref("message.role"), "assistant"), "model", "user"),
parts: [{ text: ref("message.content") }]
}),