convert.go

v0.3.0
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
	json.Unmarshal(raw, &blocks)
157
	return blocks
158
}
159
160
// convertMessages converts a single Anthropic message into one or more OpenAI messages.
161
func convertMessages(msg anthropicMessage) []openaiMessage {
162
	blocks := parseContent(msg.Content)
163
164
	switch msg.Role {
165
	case "user":
166
		return convertUserMessage(blocks)
167
	case "assistant":
168
		return convertAssistantMessage(blocks)
169
	default:
170
		// Pass through as-is.
171
		text := extractText(blocks)
172
		return []openaiMessage{{Role: msg.Role, Content: text}}
173
	}
174
}
175
176
func convertUserMessage(blocks []contentBlock) []openaiMessage {
177
	var msgs []openaiMessage
178
	var textParts []string
179
180
	for _, b := range blocks {
181
		switch b.Type {
182
		case "text":
183
			textParts = append(textParts, b.Text)
184
		case "tool_result":
185
			content := extractToolResultContent(b)
186
			msgs = append(msgs, openaiMessage{
187
				Role:       "tool",
188
				Content:    content,
189
				ToolCallID: b.ToolUseID,
190
			})
191
		}
192
	}
193
194
	// Put text before tool results so ordering stays sensible.
195
	if len(textParts) > 0 {
196
		out := []openaiMessage{{Role: "user", Content: strings.Join(textParts, "\n")}}
197
		return append(out, msgs...)
198
	}
199
	return msgs
200
}
201
202
func convertAssistantMessage(blocks []contentBlock) []openaiMessage {
203
	var textParts []string
204
	var toolCalls []openaiToolCall
205
206
	for _, b := range blocks {
207
		switch b.Type {
208
		case "text":
209
			textParts = append(textParts, b.Text)
210
		case "tool_use":
211
			args := "{}"
212
			if len(b.Input) > 0 {
213
				args = string(b.Input)
214
			}
215
			toolCalls = append(toolCalls, openaiToolCall{
216
				ID:   b.ID,
217
				Type: "function",
218
				Function: openaiCallFunc{
219
					Name:      b.Name,
220
					Arguments: args,
221
				},
222
			})
223
		}
224
	}
225
226
	msg := openaiMessage{Role: "assistant"}
227
	if len(textParts) > 0 {
228
		msg.Content = strings.Join(textParts, "\n")
229
	}
230
	if len(toolCalls) > 0 {
231
		msg.ToolCalls = toolCalls
232
	}
233
	// Some providers (e.g. NVIDIA NIM) reject empty content on assistant messages.
234
	if msg.Content == nil && len(msg.ToolCalls) == 0 {
235
		msg.Content = " "
236
	}
237
	return []openaiMessage{msg}
238
}
239
240
func extractText(blocks []contentBlock) string {
241
	var parts []string
242
	for _, b := range blocks {
243
		if b.Type == "text" {
244
			parts = append(parts, b.Text)
245
		}
246
	}
247
	return strings.Join(parts, "\n")
248
}
249
250
func extractToolResultContent(b contentBlock) string {
251
	if len(b.Content) == 0 {
252
		return ""
253
	}
254
	var s string
255
	if json.Unmarshal(b.Content, &s) == nil {
256
		return s
257
	}
258
	var blocks []contentBlock
259
	if json.Unmarshal(b.Content, &blocks) == nil {
260
		return extractText(blocks)
261
	}
262
	return string(b.Content)
263
}
264
265
func mapStopReason(fr *string) *string {
266
	if fr == nil {
267
		return nil
268
	}
269
	var mapped string
270
	switch *fr {
271
	case "stop":
272
		mapped = "end_turn"
273
	case "length":
274
		mapped = "max_tokens"
275
	case "tool_calls":
276
		mapped = "tool_use"
277
	default:
278
		mapped = "end_turn"
279
	}
280
	return &mapped
281
}
282
283
func genHex(n int) string {
284
	b := make([]byte, n)
285
	rand.Read(b)
286
	return fmt.Sprintf("%x", b)
287
}
288

Source Files