- Implement secure config file location (/tmp/.yao/proxy.json) instead of user-visible /workspace/
- Add generic options map support for backend-specific parameters (e.g., thinking for Volcengine GLM-4.7)
- Add secrets support for passing sensitive env vars (e.g., GITHUB_TOKEN) to sandbox container
- Remove excessive debug logs, keep critical ones with log.Printf("[Sandbox]...")
- Fix test assertions for system prompt passing via CLI args instead of env var
- Update i18n messages for sandbox loading states
Co-authored-by: Cursor <cursoragent@cursor.com>
461 lines
10 KiB
Go
461 lines
10 KiB
Go
package proxy
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// convertRequest converts an Anthropic request to OpenAI format
|
|
func (s *Server) convertRequest(req *AnthropicRequest) *OpenAIRequest {
|
|
// Get max_tokens from options if specified, otherwise use request value
|
|
maxTokens := req.MaxTokens
|
|
if s.config.Options != nil {
|
|
if mt, ok := s.config.Options["max_tokens"]; ok {
|
|
switch v := mt.(type) {
|
|
case float64:
|
|
maxTokens = int(v)
|
|
case int:
|
|
maxTokens = v
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get temperature from options if specified
|
|
temperature := req.Temperature
|
|
if s.config.Options != nil {
|
|
if temp, ok := s.config.Options["temperature"]; ok {
|
|
if v, ok := temp.(float64); ok {
|
|
temperature = &v
|
|
}
|
|
}
|
|
}
|
|
|
|
openaiReq := &OpenAIRequest{
|
|
Model: s.config.Model,
|
|
MaxTokens: maxTokens,
|
|
Stream: req.Stream,
|
|
Temperature: temperature,
|
|
TopP: req.TopP,
|
|
Stop: req.StopSequences,
|
|
}
|
|
|
|
// Pass through extra options (e.g., thinking, reasoning_effort, etc.)
|
|
// These are backend-specific parameters that will be merged into the request
|
|
if s.config.Options != nil {
|
|
openaiReq.ExtraOptions = make(map[string]interface{})
|
|
for k, v := range s.config.Options {
|
|
// Skip standard fields that are already handled
|
|
switch k {
|
|
case "max_tokens", "temperature", "model", "key", "proxy":
|
|
continue
|
|
default:
|
|
openaiReq.ExtraOptions[k] = v
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert messages
|
|
openaiReq.Messages = s.convertMessages(req.Messages, req.System)
|
|
|
|
// Convert tools
|
|
if len(req.Tools) > 0 {
|
|
openaiReq.Tools = s.convertTools(req.Tools)
|
|
}
|
|
|
|
// Convert tool choice
|
|
if req.ToolChoice != nil {
|
|
openaiReq.ToolChoice = s.convertToolChoice(req.ToolChoice)
|
|
}
|
|
|
|
return openaiReq
|
|
}
|
|
|
|
// convertMessages converts Anthropic messages to OpenAI format
|
|
func (s *Server) convertMessages(msgs []AnthropicMsg, system interface{}) []OpenAIMsg {
|
|
var result []OpenAIMsg
|
|
|
|
// Handle system message
|
|
if system != nil {
|
|
systemText := extractSystemText(system)
|
|
if systemText != "" {
|
|
result = append(result, OpenAIMsg{
|
|
Role: "system",
|
|
Content: systemText,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Convert each message
|
|
for _, msg := range msgs {
|
|
converted := s.convertMessage(msg)
|
|
result = append(result, converted...)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// convertMessage converts a single Anthropic message to OpenAI format
|
|
func (s *Server) convertMessage(msg AnthropicMsg) []OpenAIMsg {
|
|
var result []OpenAIMsg
|
|
|
|
// Handle content
|
|
switch content := msg.Content.(type) {
|
|
case string:
|
|
result = append(result, OpenAIMsg{
|
|
Role: mapRole(msg.Role),
|
|
Content: content,
|
|
})
|
|
|
|
case []interface{}:
|
|
// Check if this contains tool results
|
|
var toolResults []ContentBlock
|
|
var otherContent []interface{}
|
|
|
|
for _, item := range content {
|
|
block := parseContentBlock(item)
|
|
if block.Type == "tool_result" {
|
|
toolResults = append(toolResults, block)
|
|
} else {
|
|
otherContent = append(otherContent, item)
|
|
}
|
|
}
|
|
|
|
// Convert tool results to separate tool messages
|
|
for _, tr := range toolResults {
|
|
toolMsg := OpenAIMsg{
|
|
Role: "tool",
|
|
ToolCallID: tr.ToolUseID,
|
|
Content: extractToolResultContent(tr.Content),
|
|
}
|
|
result = append(result, toolMsg)
|
|
}
|
|
|
|
// Convert other content
|
|
if len(otherContent) > 0 {
|
|
openaiContent := s.convertContentBlocks(otherContent)
|
|
if len(openaiContent) == 1 && openaiContent[0].Type == "text" {
|
|
result = append(result, OpenAIMsg{
|
|
Role: mapRole(msg.Role),
|
|
Content: openaiContent[0].Text,
|
|
})
|
|
} else if len(openaiContent) > 0 {
|
|
result = append(result, OpenAIMsg{
|
|
Role: mapRole(msg.Role),
|
|
Content: openaiContent,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Handle assistant message with tool_use
|
|
if msg.Role == "assistant" {
|
|
toolCalls := extractToolUseBlocks(content)
|
|
if len(toolCalls) > 0 {
|
|
// Find or create assistant message
|
|
found := false
|
|
for i := range result {
|
|
if result[i].Role == "assistant" {
|
|
result[i].ToolCalls = toolCalls
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
result = append(result, OpenAIMsg{
|
|
Role: "assistant",
|
|
Content: "",
|
|
ToolCalls: toolCalls,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// convertContentBlocks converts Anthropic content blocks to OpenAI format
|
|
func (s *Server) convertContentBlocks(blocks []interface{}) []OpenAIContent {
|
|
var result []OpenAIContent
|
|
|
|
for _, item := range blocks {
|
|
block := parseContentBlock(item)
|
|
|
|
switch block.Type {
|
|
case "text":
|
|
result = append(result, OpenAIContent{
|
|
Type: "text",
|
|
Text: block.Text,
|
|
})
|
|
|
|
case "image":
|
|
if block.Source != nil {
|
|
imageURL := convertImageSource(block.Source)
|
|
result = append(result, OpenAIContent{
|
|
Type: "image_url",
|
|
ImageURL: imageURL,
|
|
})
|
|
}
|
|
|
|
case "tool_use", "tool_result":
|
|
// Handled separately
|
|
continue
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// convertImageSource converts Anthropic image source to OpenAI image URL
|
|
func convertImageSource(source *ImageSource) *OpenAIImageURL {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
|
|
switch source.Type {
|
|
case "base64":
|
|
// Convert to data URI
|
|
mediaType := source.MediaType
|
|
if mediaType == "" {
|
|
mediaType = "image/jpeg"
|
|
}
|
|
return &OpenAIImageURL{
|
|
URL: fmt.Sprintf("data:%s;base64,%s", mediaType, source.Data),
|
|
}
|
|
case "url":
|
|
return &OpenAIImageURL{
|
|
URL: source.URL,
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// convertTools converts Anthropic tools to OpenAI format
|
|
func (s *Server) convertTools(tools []AnthropicTool) []OpenAITool {
|
|
var result []OpenAITool
|
|
|
|
for _, tool := range tools {
|
|
result = append(result, OpenAITool{
|
|
Type: "function",
|
|
Function: OpenAIFunction{
|
|
Name: tool.Name,
|
|
Description: tool.Description,
|
|
Parameters: tool.InputSchema,
|
|
},
|
|
})
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// convertToolChoice converts Anthropic tool choice to OpenAI format
|
|
func (s *Server) convertToolChoice(choice *AnthropicToolChoice) interface{} {
|
|
if choice == nil {
|
|
return nil
|
|
}
|
|
|
|
switch choice.Type {
|
|
case "auto":
|
|
return "auto"
|
|
case "any":
|
|
return "required"
|
|
case "tool":
|
|
return map[string]interface{}{
|
|
"type": "function",
|
|
"function": map[string]string{
|
|
"name": choice.Name,
|
|
},
|
|
}
|
|
case "none":
|
|
return "none"
|
|
}
|
|
|
|
return "auto"
|
|
}
|
|
|
|
// convertResponse converts an OpenAI response to Anthropic format
|
|
func (s *Server) convertResponse(resp *OpenAIResponse) *AnthropicResponse {
|
|
result := &AnthropicResponse{
|
|
ID: generateID("msg_"),
|
|
Type: "message",
|
|
Role: "assistant",
|
|
Content: []ContentBlock{},
|
|
Model: s.config.Model,
|
|
}
|
|
|
|
if len(resp.Choices) > 0 {
|
|
choice := resp.Choices[0]
|
|
|
|
// Convert content
|
|
if content, ok := choice.Message.Content.(string); ok && content != "" {
|
|
result.Content = append(result.Content, ContentBlock{
|
|
Type: "text",
|
|
Text: content,
|
|
})
|
|
}
|
|
|
|
// Convert tool calls
|
|
for _, tc := range choice.Message.ToolCalls {
|
|
var input interface{}
|
|
json.Unmarshal([]byte(tc.Function.Arguments), &input)
|
|
|
|
result.Content = append(result.Content, ContentBlock{
|
|
Type: "tool_use",
|
|
ID: tc.ID,
|
|
Name: tc.Function.Name,
|
|
Input: input,
|
|
})
|
|
}
|
|
|
|
// Convert stop reason
|
|
stopReason := mapFinishReason(choice.FinishReason)
|
|
result.StopReason = &stopReason
|
|
}
|
|
|
|
// Convert usage
|
|
if resp.Usage != nil {
|
|
result.Usage = &Usage{
|
|
InputTokens: resp.Usage.PromptTokens,
|
|
OutputTokens: resp.Usage.CompletionTokens,
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// Helper functions
|
|
|
|
func extractSystemText(system interface{}) string {
|
|
switch s := system.(type) {
|
|
case string:
|
|
return s
|
|
case []interface{}:
|
|
var texts []string
|
|
for _, item := range s {
|
|
if block, ok := item.(map[string]interface{}); ok {
|
|
if text, ok := block["text"].(string); ok {
|
|
// Skip billing headers and other metadata
|
|
if strings.HasPrefix(text, "x-anthropic-") {
|
|
continue
|
|
}
|
|
texts = append(texts, text)
|
|
}
|
|
}
|
|
}
|
|
// Concatenate all system texts with newlines
|
|
if len(texts) > 0 {
|
|
return strings.Join(texts, "\n\n")
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseContentBlock(item interface{}) ContentBlock {
|
|
var block ContentBlock
|
|
|
|
switch v := item.(type) {
|
|
case map[string]interface{}:
|
|
if t, ok := v["type"].(string); ok {
|
|
block.Type = t
|
|
}
|
|
if text, ok := v["text"].(string); ok {
|
|
block.Text = text
|
|
}
|
|
if id, ok := v["id"].(string); ok {
|
|
block.ID = id
|
|
}
|
|
if name, ok := v["name"].(string); ok {
|
|
block.Name = name
|
|
}
|
|
if input, ok := v["input"]; ok {
|
|
block.Input = input
|
|
}
|
|
if toolUseID, ok := v["tool_use_id"].(string); ok {
|
|
block.ToolUseID = toolUseID
|
|
}
|
|
if content, ok := v["content"]; ok {
|
|
block.Content = content
|
|
}
|
|
if isError, ok := v["is_error"].(bool); ok {
|
|
block.IsError = isError
|
|
}
|
|
if source, ok := v["source"].(map[string]interface{}); ok {
|
|
block.Source = parseImageSource(source)
|
|
}
|
|
}
|
|
|
|
return block
|
|
}
|
|
|
|
func parseImageSource(source map[string]interface{}) *ImageSource {
|
|
if source == nil {
|
|
return nil
|
|
}
|
|
|
|
result := &ImageSource{}
|
|
if t, ok := source["type"].(string); ok {
|
|
result.Type = t
|
|
}
|
|
if mediaType, ok := source["media_type"].(string); ok {
|
|
result.MediaType = mediaType
|
|
}
|
|
if data, ok := source["data"].(string); ok {
|
|
result.Data = data
|
|
}
|
|
if url, ok := source["url"].(string); ok {
|
|
result.URL = url
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func extractToolUseBlocks(content []interface{}) []OpenAIToolCall {
|
|
var result []OpenAIToolCall
|
|
|
|
for _, item := range content {
|
|
block := parseContentBlock(item)
|
|
if block.Type == "tool_use" {
|
|
args, _ := json.Marshal(block.Input)
|
|
result = append(result, OpenAIToolCall{
|
|
ID: block.ID,
|
|
Type: "function",
|
|
Function: OpenAIFunctionCall{
|
|
Name: block.Name,
|
|
Arguments: string(args),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
func extractToolResultContent(content interface{}) string {
|
|
switch c := content.(type) {
|
|
case string:
|
|
return c
|
|
case []interface{}:
|
|
for _, item := range c {
|
|
if block, ok := item.(map[string]interface{}); ok {
|
|
if block["type"] == "text" {
|
|
if text, ok := block["text"].(string); ok {
|
|
return text
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func mapRole(role string) string {
|
|
switch role {
|
|
case "user":
|
|
return "user"
|
|
case "assistant":
|
|
return "assistant"
|
|
default:
|
|
return role
|
|
}
|
|
}
|