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.
This commit is contained in:
parent
dd08881027
commit
fcfe609344
14 changed files with 1306 additions and 119 deletions
74
.github/workflows/pr-test.yml
vendored
74
.github/workflows/pr-test.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
90
.github/workflows/unit-test.yml
vendored
90
.github/workflows/unit-test.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package main
|
||||
|
||||
import proxy "github.com/yaoapp/yao/sandbox/v2/docker/bin/openai-proxy"
|
||||
|
||||
func main() {
|
||||
proxy.Main()
|
||||
}
|
||||
419
sandbox/v2/docker/bin/openai-proxy/convert.go
Normal file
419
sandbox/v2/docker/bin/openai-proxy/convert.go
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
510
sandbox/v2/docker/bin/openai-proxy/main.go
Normal file
510
sandbox/v2/docker/bin/openai-proxy/main.go
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
244
sandbox/v2/docker/bin/openai-proxy/types.go
Normal file
244
sandbox/v2/docker/bin/openai-proxy/types.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue