From fcfe60934426135e10b04aea25b3fa8984e3029c Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 5 Mar 2026 21:58:04 +0800 Subject: [PATCH] Refactor CI workflows for Tai service and update health checks - Rename and enhance the Docker instance startup steps for Tai in CI workflows, improving clarity and readiness checks for both HTTP and gRPC services. - Update health check logic to ensure accurate reporting of service readiness, including specific error messages for failures. - Modify environment variable configurations to streamline the setup for K8s and Docker instances, ensuring consistent port usage across tests. These changes improve the reliability and clarity of the CI processes for the Tai service, enhancing overall testing and deployment workflows. --- .github/workflows/pr-test.yml | 74 +-- .github/workflows/unit-test.yml | 90 ++-- sandbox/v2/DESIGN.md | 10 +- sandbox/v2/IMPL.md | 30 +- sandbox/v2/TEST.md | 2 +- sandbox/v2/docker/base/Dockerfile | 6 +- sandbox/v2/docker/base/entrypoint.sh | 6 +- .../bin/openai-proxy/cmd/openai-proxy/main.go | 7 + sandbox/v2/docker/bin/openai-proxy/convert.go | 419 ++++++++++++++ sandbox/v2/docker/bin/openai-proxy/main.go | 510 ++++++++++++++++++ sandbox/v2/docker/bin/openai-proxy/types.go | 244 +++++++++ sandbox/v2/docker/build.sh | 12 +- sandbox/v2/testutils_test.go | 5 +- tai/tai_test.go | 10 +- 14 files changed, 1306 insertions(+), 119 deletions(-) create mode 100644 sandbox/v2/docker/bin/openai-proxy/cmd/openai-proxy/main.go create mode 100644 sandbox/v2/docker/bin/openai-proxy/convert.go create mode 100644 sandbox/v2/docker/bin/openai-proxy/main.go create mode 100644 sandbox/v2/docker/bin/openai-proxy/types.go 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"),