327 lines
9.8 KiB
Go
327 lines
9.8 KiB
Go
package openai
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// ContentToText flattens OpenAI content parts into plain text.
|
|
func ContentToText(parts []map[string]any) string {
|
|
var out []string
|
|
for _, p := range parts {
|
|
t, _ := p["type"].(string)
|
|
switch t {
|
|
case "text", "input_text", "output_text":
|
|
if v, ok := p["text"].(string); ok && v != "" {
|
|
out = append(out, v)
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(out, "\n")
|
|
}
|
|
|
|
type PromptOptions struct {
|
|
Tools []ToolDef
|
|
ToolChoice json.RawMessage
|
|
Temperature *float64
|
|
TopP *float64
|
|
MaxTokens *int
|
|
Stop []string
|
|
ResponseFormat *ResponseFormat
|
|
SystemHint string
|
|
}
|
|
|
|
// BuildPrompt flattens a chat transcript into the single prompt string the
|
|
// upstream AIPKit endpoint expects.
|
|
func BuildPrompt(messages []ChatMessage, opts PromptOptions) string {
|
|
parts := make([]string, 0, len(messages)+4)
|
|
for _, msg := range messages {
|
|
content := msg.Content.String()
|
|
switch msg.Role {
|
|
case "system", "developer":
|
|
tag := "System"
|
|
if msg.Role == "developer" {
|
|
tag = "Developer"
|
|
}
|
|
if opts.SystemHint != "" {
|
|
content = strings.TrimSpace(content + "\n\n" + opts.SystemHint)
|
|
}
|
|
parts = append(parts, fmt.Sprintf("[%s]\n%s", tag, content))
|
|
case "user":
|
|
parts = append(parts, "[User]\n"+content)
|
|
case "assistant":
|
|
if len(msg.ToolCalls) > 0 {
|
|
raw, _ := json.Marshal(msg.ToolCalls)
|
|
parts = append(parts, fmt.Sprintf("[Assistant]\n%s\n[Tool Calls]\n%s", content, raw))
|
|
} else {
|
|
parts = append(parts, "[Assistant]\n"+content)
|
|
}
|
|
case "tool":
|
|
parts = append(parts, fmt.Sprintf("[Tool Result (id=%s)]\n%s", msg.ToolCallID, content))
|
|
default:
|
|
title := "Message"
|
|
if msg.Role != "" {
|
|
title = strings.ToUpper(msg.Role[:1]) + msg.Role[1:]
|
|
}
|
|
parts = append(parts, fmt.Sprintf("[%s]\n%s", title, content))
|
|
}
|
|
}
|
|
if len(opts.Tools) > 0 {
|
|
parts = append(parts, toolsPrompt(opts.Tools, opts.ToolChoice))
|
|
}
|
|
if tail := samplingPrompt(opts); tail != "" {
|
|
parts = append(parts, tail)
|
|
}
|
|
return strings.Join(parts, "\n\n")
|
|
}
|
|
|
|
func samplingPrompt(opts PromptOptions) string {
|
|
var lines []string
|
|
add := func(format string, a ...any) {
|
|
lines = append(lines, fmt.Sprintf(format, a...))
|
|
}
|
|
if opts.Temperature != nil {
|
|
add("- Temperature: %.3g (0 = most deterministic, 2 = most creative).", *opts.Temperature)
|
|
}
|
|
if opts.TopP != nil {
|
|
add("- Top-p nucleus sampling: %.3g.", *opts.TopP)
|
|
}
|
|
if opts.MaxTokens != nil {
|
|
add("- Hard limit: produce at most %d tokens of output, then stop.", *opts.MaxTokens)
|
|
}
|
|
if len(opts.Stop) > 0 {
|
|
add("- Stop sequences (do not emit them): %s", strings.Join(opts.Stop, " | "))
|
|
}
|
|
if opts.ResponseFormat != nil {
|
|
switch opts.ResponseFormat.Type {
|
|
case "json_object":
|
|
add("- Respond with a single valid JSON object only, no prose, no markdown fences.")
|
|
case "json_schema":
|
|
add("- Respond with a single valid JSON object only, no prose, no markdown fences.")
|
|
// schema 必须进入约束,否则声明的 json_schema 完全没生效,
|
|
// 客户端拿到的对象无法通过它自己的 schema 校验。
|
|
if schema := jsonSchemaConstraint(opts.ResponseFormat.JSONSchema); schema != "" {
|
|
add("- The JSON must validate against this JSON Schema: %s", schema)
|
|
}
|
|
}
|
|
}
|
|
if len(lines) == 0 {
|
|
return ""
|
|
}
|
|
return "=== GENERATION CONSTRAINTS ===\n" + strings.Join(lines, "\n") + "\n=== END GENERATION CONSTRAINTS ==="
|
|
}
|
|
|
|
// jsonSchemaConstraint 取出 response_format.json_schema 里的 schema 主体;
|
|
// 结构不是预期形状时退回原始负载,至少让模型看到声明。
|
|
func jsonSchemaConstraint(raw json.RawMessage) string {
|
|
if len(raw) == 0 {
|
|
return ""
|
|
}
|
|
var fs struct {
|
|
Schema json.RawMessage `json:"schema"`
|
|
}
|
|
if err := json.Unmarshal(raw, &fs); err == nil && len(fs.Schema) > 0 {
|
|
return string(fs.Schema)
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
// ResponsesToMessages converts an OpenAI Responses API request into chat
|
|
// messages. Besides plain message items it understands function_call and
|
|
// function_call_output items so multi-turn tool loops round-trip.
|
|
func ResponsesToMessages(req *ResponsesRequest) ([]ChatMessage, error) {
|
|
var out []ChatMessage
|
|
if req.Instructions != "" {
|
|
out = append(out, ChatMessage{Role: "system", Content: MessageContent{Text: req.Instructions}})
|
|
}
|
|
trimmed := strings.TrimSpace(string(req.Input))
|
|
if trimmed == "" {
|
|
return nil, fmt.Errorf("input is required")
|
|
}
|
|
if trimmed[0] == '"' {
|
|
var s string
|
|
if err := json.Unmarshal(req.Input, &s); err != nil {
|
|
return nil, fmt.Errorf("invalid input: %w", err)
|
|
}
|
|
out = append(out, ChatMessage{Role: "user", Content: MessageContent{Text: s}})
|
|
return out, nil
|
|
}
|
|
var items []json.RawMessage
|
|
if err := json.Unmarshal(req.Input, &items); err != nil {
|
|
return nil, fmt.Errorf("invalid input: %w", err)
|
|
}
|
|
for _, raw := range items {
|
|
var probe struct {
|
|
Type string `json:"type"`
|
|
Role string `json:"role"`
|
|
}
|
|
if err := json.Unmarshal(raw, &probe); err != nil {
|
|
continue
|
|
}
|
|
switch probe.Type {
|
|
case "function_call":
|
|
var fc struct {
|
|
CallID string `json:"call_id"`
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Arguments json.RawMessage `json:"arguments"`
|
|
}
|
|
_ = json.Unmarshal(raw, &fc)
|
|
id := fc.CallID
|
|
if id == "" {
|
|
id = fc.ID
|
|
}
|
|
if id == "" {
|
|
id = "call_" + randomHex(8)
|
|
}
|
|
out = append(out, ChatMessage{Role: "assistant", ToolCalls: []ToolCall{{
|
|
ID: id, Type: "function",
|
|
Function: FunctionCall{Name: fc.Name, Arguments: rawArgText(fc.Arguments)},
|
|
}}})
|
|
case "function_call_output":
|
|
var fco struct {
|
|
CallID string `json:"call_id"`
|
|
Output json.RawMessage `json:"output"`
|
|
}
|
|
_ = json.Unmarshal(raw, &fco)
|
|
out = append(out, ChatMessage{
|
|
Role: "tool",
|
|
ToolCallID: fco.CallID,
|
|
Content: MessageContent{Text: rawArgText(fco.Output)},
|
|
})
|
|
case "message", "":
|
|
role := probe.Role
|
|
if role == "" {
|
|
role = "user"
|
|
}
|
|
var it struct {
|
|
Content json.RawMessage `json:"content"`
|
|
}
|
|
_ = json.Unmarshal(raw, &it)
|
|
content := MessageContent{}
|
|
_ = json.Unmarshal(it.Content, &content)
|
|
out = append(out, ChatMessage{Role: role, Content: content})
|
|
default:
|
|
// reasoning and other item types have no chat representation
|
|
}
|
|
}
|
|
BackfillToolIDs(out)
|
|
return out, nil
|
|
}
|
|
|
|
// rawArgText renders a Responses field that may arrive either as a JSON string
|
|
// or as a bare JSON value into plain text.
|
|
func rawArgText(raw json.RawMessage) string {
|
|
trim := bytes.TrimSpace(raw)
|
|
if len(trim) == 0 || string(trim) == "null" {
|
|
return ""
|
|
}
|
|
if trim[0] == '"' {
|
|
var s string
|
|
if json.Unmarshal(trim, &s) == nil {
|
|
return s
|
|
}
|
|
return ""
|
|
}
|
|
return string(trim)
|
|
}
|
|
|
|
func toolsPrompt(tools []ToolDef, toolChoice json.RawMessage) string {
|
|
lines := []string{
|
|
"",
|
|
"=== TOOL INSTRUCTIONS ===",
|
|
"You have access to the following tools. When you need to use a tool, " +
|
|
"you MUST output ONLY a single JSON object in this exact format " +
|
|
"(no markdown, no explanations, no extra text before or after the JSON):",
|
|
"",
|
|
`{"tool_calls": [{"id": "call_xxx", "type": "function", "function": {"name": "tool_name", "arguments": "{\"param\": \"value\"}"}}]}`,
|
|
"",
|
|
// DeepSeek 模型有时会退回自己的 DSML 标记方言(<|DSML|> calls> /
|
|
// invoke / parameter)。解析层已兼容该方言作为兜底(见 dsml.go),
|
|
// 但指令层仍显式禁止:JSON 是唯一保证完整送达的主路径,DSML 不走
|
|
// 流式增量、只在收尾整体补发,让模型偏离主路径没有好处。
|
|
"Never call tools with DSML/XML-style markup (for example `<|DSML|> calls>` or " +
|
|
"`<|DSML|> invoke name=\"...\">`). Those tags are discarded — only the " +
|
|
"JSON format above is accepted.",
|
|
"",
|
|
"Available tools:",
|
|
}
|
|
for i, tool := range tools {
|
|
fn := tool.Function
|
|
desc := strings.TrimSpace(fn.Description)
|
|
if desc == "" {
|
|
desc = "No description"
|
|
}
|
|
paramDesc := ""
|
|
if len(fn.Parameters) > 0 {
|
|
var schema struct {
|
|
Properties map[string]struct {
|
|
Type string `json:"type"`
|
|
} `json:"properties"`
|
|
Required []string `json:"required"`
|
|
}
|
|
if err := json.Unmarshal(fn.Parameters, &schema); err == nil && len(schema.Properties) > 0 {
|
|
required := map[string]bool{}
|
|
for _, r := range schema.Required {
|
|
required[r] = true
|
|
}
|
|
var pl []string
|
|
for name, p := range schema.Properties {
|
|
ptype := p.Type
|
|
if ptype == "" {
|
|
ptype = "any"
|
|
}
|
|
mark := ""
|
|
if required[name] {
|
|
mark = " (required)"
|
|
}
|
|
pl = append(pl, fmt.Sprintf(" - %s: %s%s", name, ptype, mark))
|
|
}
|
|
// Keep the schema order stable across runs.
|
|
sortStrings(pl)
|
|
paramDesc = "\n" + strings.Join(pl, "\n")
|
|
}
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%d. %s: %s%s", i+1, fn.Name, desc, paramDesc))
|
|
}
|
|
|
|
forced := ""
|
|
if len(toolChoice) > 0 {
|
|
var tc struct {
|
|
Function struct {
|
|
Name string `json:"name"`
|
|
} `json:"function"`
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.Unmarshal(toolChoice, &tc); err == nil && (tc.Type == "function" || tc.Function.Name != "") {
|
|
forced = tc.Function.Name
|
|
if forced == "" {
|
|
forced = tc.Name
|
|
}
|
|
} else if s := strings.Trim(string(toolChoice), `"`); s != "auto" && s != "none" && s != "" && s != "required" && !strings.HasPrefix(s, "{") {
|
|
forced = s
|
|
}
|
|
}
|
|
lines = append(lines, "")
|
|
if forced != "" {
|
|
lines = append(lines,
|
|
fmt.Sprintf("IMPORTANT: You MUST use the tool '%s' for this request. Output ONLY the JSON object above, nothing else.", forced))
|
|
} else {
|
|
lines = append(lines,
|
|
"If no tool is needed, reply normally. If a tool is needed, output ONLY the JSON object above.")
|
|
}
|
|
lines = append(lines, "=== END TOOL INSTRUCTIONS ===", "")
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func sortStrings(s []string) {
|
|
for i := 1; i < len(s); i++ {
|
|
for j := i; j > 0 && s[j] < s[j-1]; j-- {
|
|
s[j], s[j-1] = s[j-1], s[j]
|
|
}
|
|
}
|
|
}
|