diff --git a/backend/internal/protocol/manifest.go b/backend/internal/protocol/manifest.go index 2aa438b1f..acc45bf56 100644 --- a/backend/internal/protocol/manifest.go +++ b/backend/internal/protocol/manifest.go @@ -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 { diff --git a/backend/internal/protocol/official_catalog_test.go b/backend/internal/protocol/official_catalog_test.go index 168545f79..1d81c9ed8 100644 --- a/backend/internal/protocol/official_catalog_test.go +++ b/backend/internal/protocol/official_catalog_test.go @@ -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)) diff --git a/plugin-packages/generate-catalog.mjs b/plugin-packages/generate-catalog.mjs index a8e1c976d..a5b9d0668 100644 --- a/plugin-packages/generate-catalog.mjs +++ b/plugin-packages/generate-catalog.mjs @@ -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") }] }), diff --git a/plugin-packages/google-gemini-generate-content.yingce-plugin b/plugin-packages/google-gemini-generate-content.yingce-plugin index afccae8ff..00aedd4dc 100644 Binary files a/plugin-packages/google-gemini-generate-content.yingce-plugin and b/plugin-packages/google-gemini-generate-content.yingce-plugin differ diff --git a/plugin-packages/openai-responses.yingce-plugin b/plugin-packages/openai-responses.yingce-plugin index f0ee2178b..42b785636 100644 Binary files a/plugin-packages/openai-responses.yingce-plugin and b/plugin-packages/openai-responses.yingce-plugin differ