convert.go

v0.6.1
Doc Versions Source
1
package proxy
2
3
import (
4
	"crypto/rand"
5
	"encoding/json"
6
	"fmt"
7
	"strings"
8
)
9
10
// convertRequest translates an Anthropic Messages request to an OpenAI Chat Completions request.
11
func convertRequest(req *anthropicRequest) *openaiRequest {
12
	oai := &openaiRequest{
13
		Model:       req.Model,
14
		MaxTokens:   &req.MaxTokens,
15
		Temperature: req.Temperature,
16
		TopP:        req.TopP,
17
		Stream:      req.Stream,
18
		Stop:        req.StopSequences,
19
	}
20
	if req.Stream {
21
		oai.StreamOptions = &streamOptions{IncludeUsage: true}
22
	}
23
24
	// System message.
25
	if sys := extractSystemText(req.System); sys != "" {
26
		oai.Messages = append(oai.Messages, openaiMessage{Role: "system", Content: sys})
27
	}
28
29
	// Convert messages.
30
	for _, msg := range req.Messages {
31
		oai.Messages = append(oai.Messages, convertMessages(msg)...)
32
	}
33
34
	// Tools.
35
	for _, t := range req.Tools {
36
		oai.Tools = append(oai.Tools, openaiTool{
37
			Type: "function",
38
			Function: openaiFunction{
39
				Name:        t.Name,
40
				Description: t.Description,
41
				Parameters:  t.InputSchema,
42
			},
43
		})
44
	}
45
46
	// Tool choice.
47
	if len(req.ToolChoice) > 0 {
48
		var tc anthropicToolChoice
49
		if json.Unmarshal(req.ToolChoice, &tc) == nil {
50
			switch tc.Type {
51
			case "auto":
52
				oai.ToolChoice = "auto"
53
			case "any":
54
				oai.ToolChoice = "required"
55
			case "none":
56
				oai.ToolChoice = "none"
57
			case "tool":
58
				oai.ToolChoice = map[string]any{
59
					"type":     "function",
60
					"function": map[string]string{"name": tc.Name},
61
				}
62
			}
63
		}
64
	}
65
66
	return oai
67
}
68
69
// convertResponse translates an OpenAI Chat Completions response to an Anthropic Messages response.
70
func convertResponse(oai *openaiResponse) *anthropicResponse {
71
	resp := &anthropicResponse{
72
		ID:   "msg_" + genHex(12),
73
		Type: "message",
74
		Role: "assistant",
75
	}
76
	if oai.Model != "" {
77
		resp.Model = oai.Model
78
	}
79
	if oai.Usage != nil {
80
		resp.Usage = anthropicUsage{
81
			InputTokens:  oai.Usage.PromptTokens,
82
			OutputTokens: oai.Usage.CompletionTokens,
83
		}
84
	}
85
86
	if len(oai.Choices) > 0 {
87
		ch := oai.Choices[0]
88
		resp.StopReason = mapStopReason(ch.FinishReason)
89
90
		// Text content.
91
		if s, ok := ch.Message.Content.(string); ok && s != "" {
92
			resp.Content = append(resp.Content, contentBlock{Type: "text", Text: s})
93
		}
94
95
		// Tool calls.
96
		for _, tc := range ch.Message.ToolCalls {
97
			var input json.RawMessage
98
			if tc.Function.Arguments != "" {
99
				input = json.RawMessage(tc.Function.Arguments)
100
			} else {
101
				input = json.RawMessage(`{}`)
102
			}
103
			resp.Content = append(resp.Content, contentBlock{
104
				Type:  "tool_use",
105
				ID:    tc.ID,
106
				Name:  tc.Function.Name,
107
				Input: input,
108
			})
109
		}
110
	}
111
112
	if len(resp.Content) == 0 {
113
		resp.Content = []contentBlock{{Type: "text", Text: ""}}
114
	}
115
	return resp
116
}
117
118
// ---------- helpers ----------
119
120
func extractSystemText(raw json.RawMessage) string {
121
	if len(raw) == 0 {
122
		return ""
123
	}
124
	// Try string first.
125
	var s string
126
	if json.Unmarshal(raw, &s) == nil {
127
		return s
128
	}
129
	// Try array of {type:"text", text:"..."}.
130
	var blocks []contentBlock
131
	if json.Unmarshal(raw, &blocks) == nil {
132
		var parts []string
133
		for _, b := range blocks {
134
			if b.Type == "text" && b.Text != "" {
135
				parts = append(parts, b.Text)
136
			}
137
		}
138
		return strings.Join(parts, "\n")
139
	}
140
	return ""
141
}
142
143
// parseContent returns content blocks from the polymorphic content field.
144
func parseContent(raw json.RawMessage) []contentBlock {
145
	if len(raw) == 0 {
146
		return nil
147
	}
148
	var s string
149
	if json.Unmarshal(raw, &s) == nil {
150
		if s == "" {
151
			return nil
152
		}
153
		return []contentBlock{{Type: "text", Text: s}}
154
	}
155
	var blocks []contentBlock
156
	if err := json.Unmarshal(raw, &blocks); err != nil {
157
		return nil
158
	}
159
	return blocks
160
}
161
162
// convertMessages converts a single Anthropic message into one or more OpenAI messages.
163
func convertMessages(msg anthropicMessage) []openaiMessage {
164
	blocks := parseContent(msg.Content)
165
166
	switch msg.Role {
167
	case "user":
168
		return convertUserMessage(blocks)
169
	case "assistant":
170
		return convertAssistantMessage(blocks)
171
	default:
172
		// Pass through as-is.
173
		text := extractText(blocks)
174
		return []openaiMessage{{Role: msg.Role, Content: text}}
175
	}
176
}
177
178
func convertUserMessage(blocks []contentBlock) []openaiMessage {
179
	var msgs []openaiMessage
180
	var textParts []string
181
182
	for _, b := range blocks {
183
		switch b.Type {
184
		case "text":
185
			textParts = append(textParts, b.Text)
186
		case "tool_result":
187
			content := extractToolResultContent(b)
188
			msgs = append(msgs, openaiMessage{
189
				Role:       "tool",
190
				Content:    content,
191
				ToolCallID: b.ToolUseID,
192
			})
193
		}
194
	}
195
196
	// Put text before tool results so ordering stays sensible.
197
	if len(textParts) > 0 {
198
		out := []openaiMessage{{Role: "user", Content: strings.Join(textParts, "\n")}}
199
		return append(out, msgs...)
200
	}
201
	return msgs
202
}
203
204
func convertAssistantMessage(blocks []contentBlock) []openaiMessage {
205
	var textParts []string
206
	var toolCalls []openaiToolCall
207
208
	for _, b := range blocks {
209
		switch b.Type {
210
		case "text":
211
			textParts = append(textParts, b.Text)
212
		case "tool_use":
213
			args := "{}"
214
			if len(b.Input) > 0 {
215
				args = string(b.Input)
216
			}
217
			toolCalls = append(toolCalls, openaiToolCall{
218
				ID:   b.ID,
219
				Type: "function",
220
				Function: openaiCallFunc{
221
					Name:      b.Name,
222
					Arguments: args,
223
				},
224
			})
225
		}
226
	}
227
228
	msg := openaiMessage{Role: "assistant"}
229
	if len(textParts) > 0 {
230
		msg.Content = strings.Join(textParts, "\n")
231
	}
232
	if len(toolCalls) > 0 {
233
		msg.ToolCalls = toolCalls
234
	}
235
	// Some providers (e.g. NVIDIA NIM) reject empty content on assistant messages.
236
	if msg.Content == nil && len(msg.ToolCalls) == 0 {
237
		msg.Content = " "
238
	}
239
	return []openaiMessage{msg}
240
}
241
242
func extractText(blocks []contentBlock) string {
243
	var parts []string
244
	for _, b := range blocks {
245
		if b.Type == "text" {
246
			parts = append(parts, b.Text)
247
		}
248
	}
249
	return strings.Join(parts, "\n")
250
}
251
252
func extractToolResultContent(b contentBlock) string {
253
	if len(b.Content) == 0 {
254
		return ""
255
	}
256
	var s string
257
	if json.Unmarshal(b.Content, &s) == nil {
258
		return s
259
	}
260
	var blocks []contentBlock
261
	if json.Unmarshal(b.Content, &blocks) == nil {
262
		return extractText(blocks)
263
	}
264
	return string(b.Content)
265
}
266
267
func mapStopReason(fr *string) *string {
268
	if fr == nil {
269
		return nil
270
	}
271
	var mapped string
272
	switch *fr {
273
	case "stop":
274
		mapped = "end_turn"
275
	case "length":
276
		mapped = "max_tokens"
277
	case "tool_calls":
278
		mapped = "tool_use"
279
	default:
280
		mapped = "end_turn"
281
	}
282
	return &mapped
283
}
284
285
func genHex(n int) string {
286
	b := make([]byte, n)
287
	_, _ = rand.Read(b)
288
	return fmt.Sprintf("%x", b)
289
}
290

Source Files