diff --git a/.github/workflows/pr-test.yml b/.github/workflows/pr-test.yml index d248087c..d27936f5 100644 --- a/.github/workflows/pr-test.yml +++ b/.github/workflows/pr-test.yml @@ -1085,50 +1085,63 @@ jobs: kubectl wait --for=condition=Ready node --all --timeout=60s k3d image import alpine:latest -c tai-test - - name: Start Tai (Docker + K8s proxy) + - name: Start Tai Docker instance + run: | + docker run -d --name tai-docker \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \ + yaoapp/tai:latest + + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then + echo "Tai Docker HTTP ready"; break + fi + echo "Waiting for Tai Docker HTTP... ($i)"; sleep 1 + done + curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1 || { + echo "::error::Tai Docker HTTP failed"; docker logs tai-docker 2>&1; exit 1 + } + + for i in $(seq 1 15); do + if nc -z 127.0.0.1 9100 2>/dev/null; then + echo "Tai Docker gRPC ready"; break + fi + echo "Waiting for Tai Docker gRPC... ($i)"; sleep 1 + done + nc -z 127.0.0.1 9100 2>/dev/null || { + echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1 + } + + - name: Start Tai K8s instance run: | K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress') echo "k3d server IP: ${K3D_IP}" - docker run -d --name tai \ + docker run -d --name tai-k8s \ --network k3d-tai-test \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \ + -p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \ -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ yaoapp/tai:latest - TAI_HTTP_READY=false for i in $(seq 1 30); do - if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then - echo "Tai HTTP is ready" - TAI_HTTP_READY=true - break + if curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1; then + echo "Tai K8s HTTP ready"; break fi - echo "Waiting for Tai HTTP... ($i)" - sleep 1 + echo "Waiting for Tai K8s HTTP... ($i)"; sleep 1 done - if [ "$TAI_HTTP_READY" != "true" ]; then - echo "::error::Tai HTTP failed to become ready within 30s" - docker logs tai 2>&1 || true - docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true - exit 1 - fi + curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1 || { + echo "::error::Tai K8s HTTP failed"; docker logs tai-k8s 2>&1; exit 1 + } - TAI_GRPC_READY=false for i in $(seq 1 15); do - if nc -z 127.0.0.1 9100 2>/dev/null; then - echo "Tai gRPC is ready" - TAI_GRPC_READY=true - break + if nc -z 127.0.0.1 9101 2>/dev/null; then + echo "Tai K8s gRPC ready"; break fi - echo "Waiting for Tai gRPC... ($i)" - sleep 1 + echo "Waiting for Tai K8s gRPC... ($i)"; sleep 1 done - if [ "$TAI_GRPC_READY" != "true" ]; then - echo "::error::Tai gRPC failed to become ready within 15s" - docker logs tai 2>&1 || true - exit 1 - fi + nc -z 127.0.0.1 9101 2>/dev/null || { + echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1 + } - name: Generate kubeconfig for Tai K8s proxy run: | @@ -1142,9 +1155,10 @@ jobs: env: TAI_TEST_HOST: "127.0.0.1" TAI_TEST_DOCKER: "tcp://127.0.0.1:2375" + TAI_TEST_GRPC_PORT: "9100" TAI_TEST_K8S_HOST: "127.0.0.1" TAI_TEST_K8S_PORT: "6443" - TAI_TEST_GRPC_PORT: "9100" + TAI_TEST_K8S_GRPC_PORT: "9101" TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml" TAI_TEST_HOST_IP: "172.17.0.1" SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100" diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index 9e58fe27..56e93df5 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -793,50 +793,63 @@ jobs: kubectl wait --for=condition=Ready node --all --timeout=60s k3d image import alpine:latest -c tai-test - - name: Start Tai (Docker + K8s proxy) + - name: Start Tai Docker instance + run: | + docker run -d --name tai-docker \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \ + yaoapp/tai:latest + + for i in $(seq 1 30); do + if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then + echo "Tai Docker HTTP ready"; break + fi + echo "Waiting for Tai Docker HTTP... ($i)"; sleep 1 + done + curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1 || { + echo "::error::Tai Docker HTTP failed"; docker logs tai-docker 2>&1; exit 1 + } + + for i in $(seq 1 15); do + if nc -z 127.0.0.1 9100 2>/dev/null; then + echo "Tai Docker gRPC ready"; break + fi + echo "Waiting for Tai Docker gRPC... ($i)"; sleep 1 + done + nc -z 127.0.0.1 9100 2>/dev/null || { + echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1 + } + + - name: Start Tai K8s instance run: | K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress') echo "k3d server IP: ${K3D_IP}" - docker run -d --name tai \ + docker run -d --name tai-k8s \ --network k3d-tai-test \ - -v /var/run/docker.sock:/var/run/docker.sock \ - -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 -p 6443:6443 \ + -p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \ -e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \ yaoapp/tai:latest - TAI_HTTP_READY=false for i in $(seq 1 30); do - if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then - echo "Tai HTTP is ready" - TAI_HTTP_READY=true - break + if curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1; then + echo "Tai K8s HTTP ready"; break fi - echo "Waiting for Tai HTTP... ($i)" - sleep 1 + echo "Waiting for Tai K8s HTTP... ($i)"; sleep 1 done - if [ "$TAI_HTTP_READY" != "true" ]; then - echo "::error::Tai HTTP failed to become ready within 30s" - docker logs tai 2>&1 || true - docker inspect tai --format='{{.State.Status}} exit={{.State.ExitCode}}' || true - exit 1 - fi + curl -sf http://127.0.0.1:8081/healthz > /dev/null 2>&1 || { + echo "::error::Tai K8s HTTP failed"; docker logs tai-k8s 2>&1; exit 1 + } - TAI_GRPC_READY=false for i in $(seq 1 15); do - if nc -z 127.0.0.1 9100 2>/dev/null; then - echo "Tai gRPC is ready" - TAI_GRPC_READY=true - break + if nc -z 127.0.0.1 9101 2>/dev/null; then + echo "Tai K8s gRPC ready"; break fi - echo "Waiting for Tai gRPC... ($i)" - sleep 1 + echo "Waiting for Tai K8s gRPC... ($i)"; sleep 1 done - if [ "$TAI_GRPC_READY" != "true" ]; then - echo "::error::Tai gRPC failed to become ready within 15s" - docker logs tai 2>&1 || true - exit 1 - fi + nc -z 127.0.0.1 9101 2>/dev/null || { + echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1 + } - name: Generate kubeconfig for Tai K8s proxy run: | @@ -850,9 +863,10 @@ jobs: env: TAI_TEST_HOST: "127.0.0.1" TAI_TEST_DOCKER: "tcp://127.0.0.1:2375" + TAI_TEST_GRPC_PORT: "9100" TAI_TEST_K8S_HOST: "127.0.0.1" TAI_TEST_K8S_PORT: "6443" - TAI_TEST_GRPC_PORT: "9100" + TAI_TEST_K8S_GRPC_PORT: "9101" TAI_TEST_KUBECONFIG: "${{ runner.temp }}/kubeconfig-tai.yml" TAI_TEST_HOST_IP: "172.17.0.1" SANDBOX_TEST_REMOTE_ADDR: "tai://127.0.0.1:9100" @@ -1416,29 +1430,25 @@ jobs: docker pull yaoapp/tai:latest docker pull alpine:latest - - name: Start Tai (Docker proxy for benchmarks) + - name: Start Tai Docker instance (benchmarks) run: | - docker run -d --name tai \ + docker run -d --name tai-docker \ -v /var/run/docker.sock:/var/run/docker.sock \ -p 8080:8080 -p 9100:9100 -p 2375:2375 -p 6080:6080 \ yaoapp/tai:latest for i in $(seq 1 30); do if curl -sf http://127.0.0.1:8080/healthz > /dev/null 2>&1; then - echo "Tai HTTP is ready" - break + echo "Tai Docker HTTP ready"; break fi - echo "Waiting for Tai HTTP... ($i)" - sleep 1 + echo "Waiting for Tai Docker HTTP... ($i)"; sleep 1 done for i in $(seq 1 15); do if nc -z 127.0.0.1 9100 2>/dev/null; then - echo "Tai gRPC is ready" - break + echo "Tai Docker gRPC ready"; break fi - echo "Waiting for Tai gRPC... ($i)" - sleep 1 + echo "Waiting for Tai Docker gRPC... ($i)"; sleep 1 done - name: Run Benchmarks diff --git a/sandbox/v2/DESIGN.md b/sandbox/v2/DESIGN.md index 15abb2f5..1fa40423 100644 --- a/sandbox/v2/DESIGN.md +++ b/sandbox/v2/DESIGN.md @@ -824,15 +824,13 @@ Docker `StopStart` ~2.2s is expected: `DefaultStopTimeout = 2s` and Docker waits - Tests: unit + integration + benchmarks - CI: consolidated SandboxV2Test + BenchmarkSandboxV2 -## Phase 2: Process + JSAPI (PENDING) +## Phase 2: JSAPI + OAuth (PENDING) | Task | Detail | |------|--------| -| `sandbox/v2/process.go` | Register `sandbox.*` process namespace | -| `sandbox/v2/jsapi/` | V8 `Sandbox()` constructor (registered in gou runtime) | -| `workspace/process.go` | Register `workspace.*` process namespace | -| Integration with `cmd/start.go` | Call `sandbox.Init()` + `sandbox.M().Start()` | +| `sandbox/v2/jsapi/` | V8 `Sandbox()` / `Workspace()` constructors (registered in gou runtime) | | Wire `openapi/oauth` | `grpc.go` currently uses random token placeholders; replace with real OAuth issue/revoke | +| Integration with `cmd/start.go` | Call `sandbox.Init()` + `sandbox.M().Start()` | ## Phase 3: Agent Integration (PENDING) @@ -850,6 +848,8 @@ Docker `StopStart` ~2.2s is expected: `DefaultStopTimeout = 2s` and Docker waits | Move `sandbox/v2` → `sandbox` | Rename package | | Delete old sandbox code | manager.go, ipc/, bridge/, vncproxy/, docker/ | | Update `cmd/start.go` | Use new init path | +| `sandbox/process.go` | Register `sandbox.*` process namespace (post-cutover) | +| `workspace/process.go` | Register `workspace.*` process namespace (post-cutover) | ## V1 vs V2 Comparison diff --git a/sandbox/v2/IMPL.md b/sandbox/v2/IMPL.md index e2357b31..4514345d 100644 --- a/sandbox/v2/IMPL.md +++ b/sandbox/v2/IMPL.md @@ -78,36 +78,14 @@ Reference: [DESIGN.md](./DESIGN.md) --- -## Phase 2: Process + JSAPI — PENDING +## Phase 2: JSAPI + OAuth — PENDING | Task | Package | Detail | |------|---------|--------| -| `process.go` | `sandbox/v2` | Register `sandbox.*` process namespace (sandbox.Create, sandbox.Exec, sandbox.ReadFile, etc.) | -| `process.go` | `workspace` | Register `workspace.*` process namespace | -| `jsapi/sandbox.go` | `sandbox/v2/jsapi` | V8 JSAPI `Sandbox()` constructor (registered in gou runtime) | +| `jsapi/sandbox.go` | `sandbox/v2/jsapi` | V8 JSAPI `Sandbox()` + `Workspace()` constructors (registered in gou runtime) | +| Wire `openapi/oauth` | `sandbox/v2/grpc.go` | `CreateContainerTokens` currently generates random strings; `RevokeContainerTokens` is a no-op. Replace with real `openapi/oauth` issue/revoke calls | | `cmd/start.go` integration | `yao` | Call `sandbox.Init(config.Conf.Sandbox)` + `sandbox.M().Start(ctx)` in startup sequence | | Heartbeat bridge | `yao/grpc` | Wire gRPC Heartbeat handler → `sandbox.M().Heartbeat()` | -| Wire `openapi/oauth` | `sandbox/v2/grpc.go` | `CreateContainerTokens` currently generates random strings; `RevokeContainerTokens` is a no-op. Replace with real `openapi/oauth` issue/revoke calls | - -### Process Registration (planned) - -``` -sandbox.pool.Add sandbox.pool.Remove sandbox.pool.List -sandbox.Create sandbox.Get sandbox.GetOrCreate -sandbox.Remove sandbox.List -sandbox.Start sandbox.Stop -sandbox.Exec sandbox.Stream sandbox.Attach -sandbox.ReadFile sandbox.WriteFile sandbox.ListDir -sandbox.RemoveFile sandbox.MkdirAll -sandbox.VNC sandbox.Proxy -sandbox.EnsureImage sandbox.ImageExists sandbox.PullImage - -workspace.Create workspace.Get workspace.List -workspace.Update workspace.Delete -workspace.ReadFile workspace.WriteFile workspace.ListDir -workspace.Remove workspace.FS -workspace.Nodes -``` ### JSAPI (planned) @@ -157,6 +135,8 @@ ws.Remove("tmp.txt") | Delete old sandbox code | manager.go, ipc/, bridge/, vncproxy/, docker/ | | Delete `DESIGN-REMOTE.md` | Superseded by tai.Client | | Update `cmd/start.go` | Use new init path | +| `sandbox/process.go` | Register `sandbox.*` process namespace (post-cutover) | +| `workspace/process.go` | Register `workspace.*` process namespace (post-cutover) | --- diff --git a/sandbox/v2/TEST.md b/sandbox/v2/TEST.md index 799709e2..c6c7cd56 100644 --- a/sandbox/v2/TEST.md +++ b/sandbox/v2/TEST.md @@ -589,7 +589,7 @@ sandbox-v2-test: Key decisions: - SQLite only — sandbox is infrastructure, not data-model dependent - Tai container provides remote mode — exercises the full proxy path -- `sandbox-v2-test` as default test image — includes `yao-grpc` (heartbeat), `claude-proxy`, Nginx, WS echo + SSE test services +- `sandbox-v2-test` as default test image — includes `yao-grpc` (heartbeat), `openai-proxy`, Nginx, WS echo + SSE test services - CI builds test image from source (Step 4.5) — ensures binary compatibility with latest tai SDK + yao-grpc changes - Attach tests (WS/SSE) use `sandbox-v2-test` image's built-in test services diff --git a/sandbox/v2/docker/base/Dockerfile b/sandbox/v2/docker/base/Dockerfile index 8ad0b125..fc5bb715 100644 --- a/sandbox/v2/docker/base/Dockerfile +++ b/sandbox/v2/docker/base/Dockerfile @@ -27,9 +27,9 @@ ARG TARGETARCH COPY yao-grpc-${TARGETARCH} /usr/local/bin/yao-grpc RUN chmod +x /usr/local/bin/yao-grpc -# claude-proxy binary -COPY claude-proxy-${TARGETARCH} /usr/local/bin/claude-proxy -RUN chmod +x /usr/local/bin/claude-proxy +# openai-proxy: Anthropic Messages API → OpenAI Chat Completions API +COPY openai-proxy-${TARGETARCH} /usr/local/bin/openai-proxy +RUN chmod +x /usr/local/bin/openai-proxy COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/sandbox/v2/docker/base/entrypoint.sh b/sandbox/v2/docker/base/entrypoint.sh index 2012b26a..1625a32d 100755 --- a/sandbox/v2/docker/base/entrypoint.sh +++ b/sandbox/v2/docker/base/entrypoint.sh @@ -1,12 +1,12 @@ #!/bin/bash -# V2 base entrypoint — conditionally starts yao-grpc and claude-proxy +# V2 base entrypoint — conditionally starts yao-grpc and openai-proxy if [ -n "$YAO_GRPC_ADDR" ] && [ -n "$YAO_SANDBOX_ID" ]; then tail -f /dev/null | yao-grpc serve & fi -if [ -n "$CLAUDE_PROXY_UPSTREAM" ]; then - claude-proxy & +if [ -n "$OPENAI_PROXY_BACKEND" ]; then + openai-proxy & fi exec "$@" diff --git a/sandbox/v2/docker/bin/openai-proxy/cmd/openai-proxy/main.go b/sandbox/v2/docker/bin/openai-proxy/cmd/openai-proxy/main.go new file mode 100644 index 00000000..13067567 --- /dev/null +++ b/sandbox/v2/docker/bin/openai-proxy/cmd/openai-proxy/main.go @@ -0,0 +1,7 @@ +package main + +import proxy "github.com/yaoapp/yao/sandbox/v2/docker/bin/openai-proxy" + +func main() { + proxy.Main() +} diff --git a/sandbox/v2/docker/bin/openai-proxy/convert.go b/sandbox/v2/docker/bin/openai-proxy/convert.go new file mode 100644 index 00000000..233bf2d6 --- /dev/null +++ b/sandbox/v2/docker/bin/openai-proxy/convert.go @@ -0,0 +1,419 @@ +package proxy + +import ( + "encoding/json" + "fmt" + "strings" +) + +func (s *Server) convertRequest(req *AnthropicRequest) *OpenAIRequest { + 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 + } + } + } + + 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, + } + + if s.config.Options != nil { + openaiReq.ExtraOptions = make(map[string]interface{}) + for k, v := range s.config.Options { + switch k { + case "max_tokens", "temperature", "model", "key", "proxy": + continue + default: + openaiReq.ExtraOptions[k] = v + } + } + } + + openaiReq.Messages = s.convertMessages(req.Messages, req.System) + + if len(req.Tools) > 0 { + openaiReq.Tools = s.convertTools(req.Tools) + } + + if req.ToolChoice != nil { + openaiReq.ToolChoice = s.convertToolChoice(req.ToolChoice) + } + + return openaiReq +} + +func (s *Server) convertMessages(msgs []AnthropicMsg, system interface{}) []OpenAIMsg { + var result []OpenAIMsg + + if system != nil { + systemText := extractSystemText(system) + if systemText != "" { + result = append(result, OpenAIMsg{ + Role: "system", + Content: systemText, + }) + } + } + + for _, msg := range msgs { + converted := s.convertMessage(msg) + result = append(result, converted...) + } + + return result +} + +func (s *Server) convertMessage(msg AnthropicMsg) []OpenAIMsg { + var result []OpenAIMsg + + switch content := msg.Content.(type) { + case string: + result = append(result, OpenAIMsg{ + Role: mapRole(msg.Role), + Content: content, + }) + + case []interface{}: + 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) + } + } + + for _, tr := range toolResults { + toolMsg := OpenAIMsg{ + Role: "tool", + ToolCallID: tr.ToolUseID, + Content: extractToolResultContent(tr.Content), + } + result = append(result, toolMsg) + } + + 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, + }) + } + } + + if msg.Role == "assistant" { + toolCalls := extractToolUseBlocks(content) + if len(toolCalls) > 0 { + 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 +} + +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": + continue + } + } + + return result +} + +func convertImageSource(source *ImageSource) *OpenAIImageURL { + if source == nil { + return nil + } + + switch source.Type { + case "base64": + 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 +} + +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 +} + +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" +} + +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] + + if content, ok := choice.Message.Content.(string); ok && content != "" { + result.Content = append(result.Content, ContentBlock{ + Type: "text", + Text: content, + }) + } + + 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, + }) + } + + stopReason := mapFinishReason(choice.FinishReason) + result.StopReason = &stopReason + } + + if resp.Usage != nil { + result.Usage = &Usage{ + InputTokens: resp.Usage.PromptTokens, + OutputTokens: resp.Usage.CompletionTokens, + } + } else { + result.Usage = &Usage{InputTokens: 0, OutputTokens: 0} + } + + return result +} + +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 { + if strings.HasPrefix(text, "x-anthropic-") { + continue + } + texts = append(texts, text) + } + } + } + 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 + } +} diff --git a/sandbox/v2/docker/bin/openai-proxy/main.go b/sandbox/v2/docker/bin/openai-proxy/main.go new file mode 100644 index 00000000..bc1004aa --- /dev/null +++ b/sandbox/v2/docker/bin/openai-proxy/main.go @@ -0,0 +1,510 @@ +// Package proxy provides a lightweight API proxy that translates +// Anthropic Messages API to OpenAI Chat Completions API. +package proxy + +import ( + "bufio" + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +// Config holds the proxy server configuration +type Config struct { + Port int + Backend string + Model string + APIKey string + Timeout int + Verbose bool + LogFile string + Options map[string]interface{} +} + +// Server is the API proxy server +type Server struct { + config *Config + client *http.Client +} + +// Main is the entry point for the proxy server +func Main() { + config := parseFlags() + if err := config.Validate(); err != nil { + log.Fatalf("Configuration error: %v", err) + } + + if config.LogFile != "" { + f, err := os.OpenFile(config.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatalf("Failed to open log file: %v", err) + } + mw := io.MultiWriter(os.Stdout, f) + log.SetOutput(mw) + } + + server := NewServer(config) + addr := fmt.Sprintf(":%d", config.Port) + + log.Printf("OpenAI Proxy starting on %s", addr) + log.Printf("Backend: %s", config.Backend) + log.Printf("Model: %s", config.Model) + if len(config.Options) > 0 { + optBytes, _ := json.Marshal(config.Options) + log.Printf("Options: %s", string(optBytes)) + } + + http.HandleFunc("/v1/messages", server.handleMessages) + http.HandleFunc("/health", server.handleHealth) + + if err := http.ListenAndServe(addr, nil); err != nil { + log.Fatalf("Server failed: %v", err) + } +} + +func parseFlags() *Config { + config := &Config{} + + flag.IntVar(&config.Port, "p", 0, "Listen port") + flag.IntVar(&config.Port, "port", 0, "Listen port") + flag.StringVar(&config.Backend, "b", "", "Backend API URL") + flag.StringVar(&config.Backend, "backend", "", "Backend API URL") + flag.StringVar(&config.Model, "m", "", "Backend model name") + flag.StringVar(&config.Model, "model", "", "Backend model name") + flag.StringVar(&config.APIKey, "k", "", "Backend API key") + flag.StringVar(&config.APIKey, "api-key", "", "Backend API key") + flag.IntVar(&config.Timeout, "t", 0, "Request timeout in seconds") + flag.IntVar(&config.Timeout, "timeout", 0, "Request timeout in seconds") + flag.BoolVar(&config.Verbose, "v", false, "Verbose logging") + flag.BoolVar(&config.Verbose, "verbose", false, "Verbose logging") + flag.StringVar(&config.LogFile, "l", "", "Log file path") + flag.StringVar(&config.LogFile, "log", "", "Log file path") + + flag.Parse() + + if config.Port == 0 { + if v := os.Getenv("OPENAI_PROXY_PORT"); v != "" { + config.Port, _ = strconv.Atoi(v) + } + } + if config.Port == 0 { + config.Port = 3456 + } + + if config.Backend == "" { + config.Backend = os.Getenv("OPENAI_PROXY_BACKEND") + } + + if config.Model == "" { + config.Model = os.Getenv("OPENAI_PROXY_MODEL") + } + + if config.APIKey == "" { + config.APIKey = os.Getenv("OPENAI_PROXY_API_KEY") + } + + if config.Timeout == 0 { + if v := os.Getenv("OPENAI_PROXY_TIMEOUT"); v != "" { + config.Timeout, _ = strconv.Atoi(v) + } + } + if config.Timeout == 0 { + config.Timeout = 300 + } + + if optionsStr := os.Getenv("OPENAI_PROXY_OPTIONS"); optionsStr != "" { + var options map[string]interface{} + if err := json.Unmarshal([]byte(optionsStr), &options); err != nil { + log.Printf("Warning: failed to parse OPENAI_PROXY_OPTIONS: %v", err) + } else { + config.Options = options + } + } + + return config +} + +// Validate checks if the configuration is valid +func (c *Config) Validate() error { + if c.Backend == "" { + return fmt.Errorf("backend URL is required (-b or OPENAI_PROXY_BACKEND)") + } + if c.Model == "" { + return fmt.Errorf("model name is required (-m or OPENAI_PROXY_MODEL)") + } + if c.APIKey == "" { + return fmt.Errorf("API key is required (-k or OPENAI_PROXY_API_KEY)") + } + return nil +} + +// NewServer creates a new proxy server +func NewServer(config *Config) *Server { + return &Server{ + config: config, + client: &http.Client{ + Timeout: time.Duration(config.Timeout) * time.Second, + }, + } +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + s.errorResponse(w, http.StatusBadRequest, "invalid_request", "Failed to read request body") + return + } + defer r.Body.Close() + + if s.config.Verbose { + log.Printf("Received request: %s", string(body)) + } + + var anthropicReq AnthropicRequest + if err := json.Unmarshal(body, &anthropicReq); err != nil { + s.errorResponse(w, http.StatusBadRequest, "invalid_request", "Invalid JSON") + return + } + + openaiReq := s.convertRequest(&anthropicReq) + + if anthropicReq.Stream { + s.handleStreamingRequest(w, openaiReq) + } else { + s.handleNonStreamingRequest(w, openaiReq) + } +} + +func (s *Server) handleNonStreamingRequest(w http.ResponseWriter, openaiReq *OpenAIRequest) { + openaiReq.Stream = false + + resp, err := s.forwardRequest(openaiReq) + if err != nil { + s.errorResponse(w, http.StatusBadGateway, "backend_error", err.Error()) + return + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + s.errorResponse(w, http.StatusBadGateway, "backend_error", "Failed to read backend response") + return + } + + if s.config.Verbose { + log.Printf("Backend response: %s", string(body)) + } + + if resp.StatusCode != http.StatusOK { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(body) + return + } + + var openaiResp OpenAIResponse + if err := json.Unmarshal(body, &openaiResp); err != nil { + s.errorResponse(w, http.StatusBadGateway, "backend_error", "Invalid backend response") + return + } + + anthropicResp := s.convertResponse(&openaiResp) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(anthropicResp) +} + +func (s *Server) handleStreamingRequest(w http.ResponseWriter, openaiReq *OpenAIRequest) { + openaiReq.Stream = true + openaiReq.StreamOptions = &StreamOptions{IncludeUsage: true} + + resp, err := s.forwardRequest(openaiReq) + if err != nil { + s.errorResponse(w, http.StatusBadGateway, "backend_error", err.Error()) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(body) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + flusher, ok := w.(http.Flusher) + if !ok { + s.errorResponse(w, http.StatusInternalServerError, "server_error", "Streaming not supported") + return + } + + msgID := generateID("msg_") + startEvent := AnthropicStreamEvent{ + Type: "message_start", + Message: &AnthropicResponse{ + ID: msgID, + Type: "message", + Role: "assistant", + Content: []ContentBlock{}, + Model: s.config.Model, + StopReason: nil, + StopSequence: nil, + Usage: &Usage{InputTokens: 0, OutputTokens: 0}, + }, + } + s.writeSSE(w, flusher, startEvent) + + s.processStream(w, flusher, resp.Body, msgID) +} + +func (s *Server) processStream(w http.ResponseWriter, flusher http.Flusher, body io.Reader, msgID string) { + scanner := bufio.NewScanner(body) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + + var contentBlockStarted bool + var currentToolCall *ToolCallAccumulator + var toolCalls []*ToolCallAccumulator + var contentIndex int + var finishReason string + var lastUsage *Usage + + for scanner.Scan() { + line := scanner.Text() + + if !strings.HasPrefix(line, "data: ") { + continue + } + + data := strings.TrimPrefix(line, "data: ") + if data == "[DONE]" { + break + } + + var chunk OpenAIStreamChunk + if err := json.Unmarshal([]byte(data), &chunk); err != nil { + if s.config.Verbose { + log.Printf("Failed to parse chunk: %s", data) + } + continue + } + + if len(chunk.Choices) == 0 { + if chunk.Usage != nil { + lastUsage = &Usage{ + InputTokens: chunk.Usage.PromptTokens, + OutputTokens: chunk.Usage.CompletionTokens, + } + } + continue + } + + choice := chunk.Choices[0] + + if choice.FinishReason != "" { + finishReason = mapFinishReason(choice.FinishReason) + } + + if len(choice.Delta.ToolCalls) > 0 { + for _, tc := range choice.Delta.ToolCalls { + if tc.Index != nil { + idx := *tc.Index + if idx >= len(toolCalls) { + if contentBlockStarted && currentToolCall == nil { + stopEvent := AnthropicStreamEvent{ + Type: "content_block_stop", + Index: contentIndex - 1, + } + s.writeSSE(w, flusher, stopEvent) + } + + currentToolCall = &ToolCallAccumulator{ + Index: idx, + ID: tc.ID, + Name: tc.Function.Name, + Args: "", + } + toolCalls = append(toolCalls, currentToolCall) + + startEvent := AnthropicStreamEvent{ + Type: "content_block_start", + Index: contentIndex, + ContentBlock: &ContentBlock{ + Type: "tool_use", + ID: tc.ID, + Name: tc.Function.Name, + Input: map[string]interface{}{}, + }, + } + s.writeSSE(w, flusher, startEvent) + contentIndex++ + } + + if tc.Function.Arguments != "" { + currentToolCall.Args += tc.Function.Arguments + deltaEvent := AnthropicStreamEvent{ + Type: "content_block_delta", + Index: contentIndex - 1, + Delta: &DeltaContent{ + Type: "input_json_delta", + PartialJSON: tc.Function.Arguments, + }, + } + s.writeSSE(w, flusher, deltaEvent) + } + } + } + continue + } + + if choice.Delta.Content != "" { + if !contentBlockStarted { + startEvent := AnthropicStreamEvent{ + Type: "content_block_start", + Index: contentIndex, + ContentBlock: &ContentBlock{ + Type: "text", + Text: "", + }, + } + s.writeSSE(w, flusher, startEvent) + contentBlockStarted = true + contentIndex++ + } + + deltaEvent := AnthropicStreamEvent{ + Type: "content_block_delta", + Index: contentIndex - 1, + Delta: &DeltaContent{ + Type: "text_delta", + Text: choice.Delta.Content, + }, + } + s.writeSSE(w, flusher, deltaEvent) + } + } + + if contentBlockStarted || len(toolCalls) > 0 { + stopEvent := AnthropicStreamEvent{ + Type: "content_block_stop", + Index: contentIndex - 1, + } + s.writeSSE(w, flusher, stopEvent) + } + + if finishReason == "" { + finishReason = "end_turn" + } + if lastUsage == nil { + lastUsage = &Usage{InputTokens: 0, OutputTokens: 0} + } + deltaEvent := AnthropicStreamEvent{ + Type: "message_delta", + Delta: &DeltaContent{ + StopReason: &finishReason, + }, + Usage: lastUsage, + } + s.writeSSE(w, flusher, deltaEvent) + + stopEvent := AnthropicStreamEvent{ + Type: "message_stop", + } + s.writeSSE(w, flusher, stopEvent) +} + +func (s *Server) writeSSE(w http.ResponseWriter, flusher http.Flusher, event interface{}) { + data, err := json.Marshal(event) + if err != nil { + return + } + + eventType := "" + if e, ok := event.(AnthropicStreamEvent); ok { + eventType = e.Type + } + + if eventType != "" { + fmt.Fprintf(w, "event: %s\n", eventType) + } + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + + if s.config.Verbose { + log.Printf("SSE event: %s", string(data)) + } +} + +func (s *Server) forwardRequest(openaiReq *OpenAIRequest) (*http.Response, error) { + body, err := json.Marshal(openaiReq) + if err != nil { + return nil, err + } + + if s.config.Verbose { + log.Printf("Forwarding to backend: %s", string(body)) + } + + req, err := http.NewRequest(http.MethodPost, s.config.Backend, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+s.config.APIKey) + + return s.client.Do(req) +} + +func (s *Server) errorResponse(w http.ResponseWriter, status int, errType, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]interface{}{ + "type": "error", + "error": map[string]string{ + "type": errType, + "message": message, + }, + }) +} + +func generateID(prefix string) string { + return fmt.Sprintf("%s%d", prefix, time.Now().UnixNano()) +} + +func mapFinishReason(reason string) string { + switch reason { + case "stop": + return "end_turn" + case "length": + return "max_tokens" + case "tool_calls", "function_call": + return "tool_use" + case "content_filter": + return "end_turn" + default: + return "end_turn" + } +} diff --git a/sandbox/v2/docker/bin/openai-proxy/types.go b/sandbox/v2/docker/bin/openai-proxy/types.go new file mode 100644 index 00000000..e62989be --- /dev/null +++ b/sandbox/v2/docker/bin/openai-proxy/types.go @@ -0,0 +1,244 @@ +package proxy + +import "encoding/json" + +// ============================================ +// Anthropic API Types +// ============================================ + +type AnthropicRequest struct { + Model string `json:"model"` + Messages []AnthropicMsg `json:"messages"` + System interface{} `json:"system,omitempty"` + MaxTokens int `json:"max_tokens"` + Stream bool `json:"stream,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + Tools []AnthropicTool `json:"tools,omitempty"` + ToolChoice *AnthropicToolChoice `json:"tool_choice,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type AnthropicMsg struct { + Role string `json:"role"` + Content interface{} `json:"content"` +} + +type ContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Source *ImageSource `json:"source,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Input interface{} `json:"input,omitempty"` + ToolUseID string `json:"tool_use_id,omitempty"` + Content interface{} `json:"content,omitempty"` + IsError bool `json:"is_error,omitempty"` +} + +type ImageSource struct { + Type string `json:"type"` + MediaType string `json:"media_type,omitempty"` + Data string `json:"data,omitempty"` + URL string `json:"url,omitempty"` +} + +type SystemBlock struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type AnthropicTool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema interface{} `json:"input_schema"` +} + +type AnthropicToolChoice struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` +} + +type AnthropicResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Content []ContentBlock `json:"content"` + Model string `json:"model"` + StopReason *string `json:"stop_reason"` + StopSequence *string `json:"stop_sequence,omitempty"` + Usage *Usage `json:"usage"` +} + +type Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +type AnthropicStreamEvent struct { + Type string `json:"type"` + Index int `json:"index,omitempty"` + Message *AnthropicResponse `json:"message,omitempty"` + ContentBlock *ContentBlock `json:"content_block,omitempty"` + Delta *DeltaContent `json:"delta,omitempty"` + Usage *Usage `json:"usage,omitempty"` +} + +type DeltaContent struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + PartialJSON string `json:"partial_json,omitempty"` + StopReason *string `json:"stop_reason,omitempty"` +} + +// ============================================ +// OpenAI API Types +// ============================================ + +type OpenAIRequest struct { + Model string `json:"model"` + Messages []OpenAIMsg `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` + StreamOptions *StreamOptions `json:"stream_options,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + Stop []string `json:"stop,omitempty"` + Tools []OpenAITool `json:"tools,omitempty"` + ToolChoice interface{} `json:"tool_choice,omitempty"` + ExtraOptions map[string]interface{} `json:"-"` +} + +func (r OpenAIRequest) MarshalJSON() ([]byte, error) { + m := map[string]interface{}{ + "model": r.Model, + "messages": r.Messages, + } + if r.MaxTokens > 0 { + m["max_tokens"] = r.MaxTokens + } + if r.Stream { + m["stream"] = r.Stream + } + if r.StreamOptions != nil { + m["stream_options"] = r.StreamOptions + } + if r.Temperature != nil { + m["temperature"] = *r.Temperature + } + if r.TopP != nil { + m["top_p"] = *r.TopP + } + if len(r.Stop) > 0 { + m["stop"] = r.Stop + } + if len(r.Tools) > 0 { + m["tools"] = r.Tools + } + if r.ToolChoice != nil { + m["tool_choice"] = r.ToolChoice + } + for k, v := range r.ExtraOptions { + if _, exists := m[k]; !exists { + m[k] = v + } + } + return json.Marshal(m) +} + +type StreamOptions struct { + IncludeUsage bool `json:"include_usage"` +} + +type OpenAIMsg struct { + Role string `json:"role"` + Content interface{} `json:"content,omitempty"` + ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + Name string `json:"name,omitempty"` +} + +type OpenAIContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL *OpenAIImageURL `json:"image_url,omitempty"` +} + +type OpenAIImageURL struct { + URL string `json:"url"` + Detail string `json:"detail,omitempty"` +} + +type OpenAITool struct { + Type string `json:"type"` + Function OpenAIFunction `json:"function"` +} + +type OpenAIFunction struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Parameters interface{} `json:"parameters"` +} + +type OpenAIToolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function OpenAIFunctionCall `json:"function"` + Index *int `json:"index,omitempty"` +} + +type OpenAIFunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +type OpenAIResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []OpenAIChoice `json:"choices"` + Usage *OpenAIUsage `json:"usage,omitempty"` +} + +type OpenAIChoice struct { + Index int `json:"index"` + Message OpenAIMsg `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type OpenAIUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type OpenAIStreamChunk struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []OpenAIStreamChoice `json:"choices"` + Usage *OpenAIUsage `json:"usage,omitempty"` +} + +type OpenAIStreamChoice struct { + Index int `json:"index"` + Delta OpenAIStreamDelta `json:"delta"` + FinishReason string `json:"finish_reason,omitempty"` +} + +type OpenAIStreamDelta struct { + Role string `json:"role,omitempty"` + Content string `json:"content,omitempty"` + ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"` +} + +type ToolCallAccumulator struct { + Index int + ID string + Name string + Args string +} diff --git a/sandbox/v2/docker/build.sh b/sandbox/v2/docker/build.sh index 0bf9ff07..f8dd7ab4 100755 --- a/sandbox/v2/docker/build.sh +++ b/sandbox/v2/docker/build.sh @@ -23,11 +23,11 @@ CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/ echo "Built: yao-grpc-amd64, yao-grpc-arm64" echo "" -echo "=== Building claude-proxy (multi-arch) ===" -cd "$YAO_ROOT/sandbox/proxy/cmd/claude-proxy" -CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/base/claude-proxy-amd64" . -CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/base/claude-proxy-arm64" . -echo "Built: claude-proxy-amd64, claude-proxy-arm64" +echo "=== Building openai-proxy (multi-arch) ===" +cd "$SCRIPT_DIR/bin/openai-proxy/cmd/openai-proxy" +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/base/openai-proxy-amd64" . +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o "$SCRIPT_DIR/base/openai-proxy-arm64" . +echo "Built: openai-proxy-amd64, openai-proxy-arm64" cd "$SCRIPT_DIR" @@ -71,7 +71,7 @@ build_image "sandbox-v2-test" "$SCRIPT_DIR/test" "$PUSH" echo "" echo "=== Cleanup ===" rm -f "$SCRIPT_DIR/base/yao-grpc-amd64" "$SCRIPT_DIR/base/yao-grpc-arm64" -rm -f "$SCRIPT_DIR/base/claude-proxy-amd64" "$SCRIPT_DIR/base/claude-proxy-arm64" +rm -f "$SCRIPT_DIR/base/openai-proxy-amd64" "$SCRIPT_DIR/base/openai-proxy-arm64" echo "Removed temporary binary files" echo "" diff --git a/sandbox/v2/testutils_test.go b/sandbox/v2/testutils_test.go index 53e5fbb7..c6fb5f1b 100644 --- a/sandbox/v2/testutils_test.go +++ b/sandbox/v2/testutils_test.go @@ -36,13 +36,14 @@ func testPools() []poolConfig { if kubeconfig == "" { return pools } - addr := fmt.Sprintf("tai://%s", host) + grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 9100)) + addr := fmt.Sprintf("tai://%s:%d", host, grpcPort) opts := []tai.Option{ tai.K8s, tai.WithKubeConfig(kubeconfig), tai.WithPorts(tai.Ports{ K8s: envPort("TAI_TEST_K8S_PORT", 6443), - GRPC: envPort("TAI_TEST_GRPC_PORT", 9100), + GRPC: grpcPort, }), } if ns := os.Getenv("TAI_TEST_K8S_NAMESPACE"); ns != "" { diff --git a/tai/tai_test.go b/tai/tai_test.go index 55afa0b7..d00838e6 100644 --- a/tai/tai_test.go +++ b/tai/tai_test.go @@ -1,6 +1,7 @@ package tai import ( + "fmt" "os" "strconv" "testing" @@ -201,14 +202,15 @@ func TestNewRemoteK8s(t *testing.T) { t.Skip("TAI_TEST_K8S_HOST or TAI_TEST_KUBECONFIG not set") } + grpcPort := envPort("TAI_TEST_K8S_GRPC_PORT", envPort("TAI_TEST_GRPC_PORT", 9100)) ports := Ports{ K8s: envPort("TAI_TEST_K8S_PORT", 6443), - GRPC: envPort("TAI_TEST_GRPC_PORT", 9100), - HTTP: envPort("TAI_TEST_HTTP_PORT", 8080), - VNC: envPort("TAI_TEST_VNC_PORT", 6080), + GRPC: grpcPort, + HTTP: envPort("TAI_TEST_K8S_HTTP_PORT", 8080), + VNC: envPort("TAI_TEST_K8S_VNC_PORT", 6080), } - c, err := New("tai://"+host, K8s, + c, err := New(fmt.Sprintf("tai://%s:%d", host, grpcPort), K8s, WithPorts(ports), WithKubeConfig(kubeconfig), WithNamespace("default"),