implemented stateless ant/oai messages/responses apis
This commit is contained in:
@@ -4,10 +4,13 @@ Dynagate is a lightweight, high-performance LLM gateway written in Go that acts
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
1. **OpenAI-compatible endpoints**:
|
1. **Multi-API Protocol Endpoints (Stateless)**:
|
||||||
- `/v1/models` (GET): Dynamically lists unique active model IDs.
|
- `/v1/models` (GET): Dynamically lists unique active model IDs.
|
||||||
- `/v1/chat/completions` (POST): Proxies non-streaming and streaming (`text/event-stream`) completions.
|
- `/v1/chat/completions` (POST): Proxies non-streaming and streaming (`text/event-stream`) completions.
|
||||||
|
- `/v1/responses` (POST): Stateless-style OpenAI Responses API endpoint (automatically routed to upstream chat completions endpoints).
|
||||||
|
- `/v1/messages` (POST): Anthropic-compatible Messages API endpoint (automatically routed to upstream chat completions endpoints).
|
||||||
- `/v1/images/generations` (POST): Proxies image generation requests.
|
- `/v1/images/generations` (POST): Proxies image generation requests.
|
||||||
|
> **Note**: Dynagate operates in a completely stateless manner across all API formats (`/v1/chat/completions`, `/v1/responses`, `/v1/messages`). Dynagate does not store or persist server-side conversation state. Clients must include the full conversation history in each request for multi-turn dialogues.
|
||||||
|
|
||||||
2. **Zero-downtime live-reloading**:
|
2. **Zero-downtime live-reloading**:
|
||||||
- Watches `models.csv` (overridable via command-line flags) continuously using a background thread and automatically reloads configuration updates without dropping active connections.
|
- Watches `models.csv` (overridable via command-line flags) continuously using a background thread and automatically reloads configuration updates without dropping active connections.
|
||||||
@@ -112,6 +115,15 @@ The gateway maps columns dynamically by looking at the header row. If no header
|
|||||||
- **`"-blank-"`**: The gateway will attach exactly `Authorization: Bearer` without any key appended.
|
- **`"-blank-"`**: The gateway will attach exactly `Authorization: Bearer` without any key appended.
|
||||||
- **Any other string**: The gateway will attach `Authorization: Bearer <key>`.
|
- **Any other string**: The gateway will attach `Authorization: Bearer <key>`.
|
||||||
|
|
||||||
|
### Stateless Operation & Multi-Turn Conversations
|
||||||
|
|
||||||
|
Dynagate is designed as a lightweight, zero-state proxy gateway. All supported request formats (`/v1/chat/completions`, `/v1/responses`, `/v1/messages`) are processed statelessly without storing session context or conversation state on disk or in memory.
|
||||||
|
|
||||||
|
To maintain context in multi-turn conversations, clients **must include the entire conversation history** in every request payload:
|
||||||
|
- **OpenAI Chat Completions (`/v1/chat/completions`)**: Pass the full array of system, user, and assistant messages in `messages`.
|
||||||
|
- **OpenAI Responses API (`/v1/responses`)**: Pass previous conversation turns in `input` (or `messages`) along with `instructions`.
|
||||||
|
- **Anthropic Messages API (`/v1/messages`)**: Pass the full history of user and assistant messages in `messages` along with `system`.
|
||||||
|
|
||||||
### Manual testing with curl
|
### Manual testing with curl
|
||||||
|
|
||||||
#### 1. Model listing (authenticated)
|
#### 1. Model listing (authenticated)
|
||||||
@@ -142,6 +154,30 @@ curl -i -X POST http://localhost:8080/v1/chat/completions \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 4. OpenAI-compatible Responses API (`/v1/responses`)
|
||||||
|
```bash
|
||||||
|
curl -i -X POST http://localhost:8080/v1/responses \
|
||||||
|
-H "Authorization: Bearer my-secure-gateway-token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"instructions": "Be concise",
|
||||||
|
"input": "Explain relativity in one sentence"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Anthropic-compatible Messages API (`/v1/messages`)
|
||||||
|
```bash
|
||||||
|
curl -i -X POST http://localhost:8080/v1/messages \
|
||||||
|
-H "Authorization: Bearer my-secure-gateway-token" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "claude-3-5-sonnet",
|
||||||
|
"system": "You are a helpful assistant",
|
||||||
|
"messages": [{"role": "user", "content": "Hello world"}]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Dynamic loading failures
|
### Dynamic loading failures
|
||||||
|
|||||||
+279
@@ -988,6 +988,285 @@ func TestFibonacciBackoff(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnthropicMessagesEndpoint(t *testing.T) {
|
||||||
|
var receivedOpenAIBody map[string]any
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/chat/completions" {
|
||||||
|
t.Errorf("Expected request to be routed to /v1/chat/completions, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
receivedOpenAIBody = body
|
||||||
|
|
||||||
|
isStream, _ := body["stream"].(bool)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if isStream {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
flusher, _ := w.(http.Flusher)
|
||||||
|
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"Hello \"}}]}\n\n"))
|
||||||
|
flusher.Flush()
|
||||||
|
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"Anthropic!\"},\"finish_reason\":\"stop\"}]}\n\n"))
|
||||||
|
flusher.Flush()
|
||||||
|
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||||
|
flusher.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
resp := map[string]any{
|
||||||
|
"id": "chatcmpl-test",
|
||||||
|
"model": "claude-model",
|
||||||
|
"choices": []any{
|
||||||
|
map[string]any{
|
||||||
|
"index": 0,
|
||||||
|
"message": map[string]any{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hello from Anthropic proxy",
|
||||||
|
},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"usage": map[string]any{
|
||||||
|
"prompt_tokens": 10,
|
||||||
|
"completion_tokens": 15,
|
||||||
|
"total_tokens": 25,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
configs := []ModelConfig{
|
||||||
|
{Model: "claude-model", Key: "test-key", Endpoint: server.URL},
|
||||||
|
}
|
||||||
|
cm := &ConfigManager{
|
||||||
|
configs: configs,
|
||||||
|
uniqueModels: []string{"claude-model"},
|
||||||
|
}
|
||||||
|
handler := handleMessages(cm, nil, 0)
|
||||||
|
|
||||||
|
// 1. Non-streaming test
|
||||||
|
t.Run("Non-streaming Anthropic message", func(t *testing.T) {
|
||||||
|
reqObj := map[string]any{
|
||||||
|
"model": "claude-model",
|
||||||
|
"system": "You are a helpful bot",
|
||||||
|
"messages": []any{
|
||||||
|
map[string]any{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Hello",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"max_tokens": 100,
|
||||||
|
}
|
||||||
|
reqBytes, _ := json.Marshal(reqObj)
|
||||||
|
req := httptest.NewRequest("POST", "/v1/messages", bytes.NewReader(reqBytes))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Result().StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("Expected 200 OK, got %d", w.Result().StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var anthropicResp map[string]any
|
||||||
|
_ = json.NewDecoder(w.Body).Decode(&anthropicResp)
|
||||||
|
|
||||||
|
if anthropicResp["type"] != "message" {
|
||||||
|
t.Errorf("Expected response type 'message', got %v", anthropicResp["type"])
|
||||||
|
}
|
||||||
|
if anthropicResp["role"] != "assistant" {
|
||||||
|
t.Errorf("Expected role 'assistant', got %v", anthropicResp["role"])
|
||||||
|
}
|
||||||
|
content, ok := anthropicResp["content"].([]any)
|
||||||
|
if !ok || len(content) == 0 {
|
||||||
|
t.Fatalf("Expected non-empty content array in Anthropic response, got %+v", anthropicResp)
|
||||||
|
}
|
||||||
|
textVal := content[0].(map[string]any)["text"].(string)
|
||||||
|
if textVal != "Hello from Anthropic proxy" {
|
||||||
|
t.Errorf("Unexpected text in response: %s", textVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify system message was prepended into OpenAI messages format
|
||||||
|
openAIMsgs, ok := receivedOpenAIBody["messages"].([]any)
|
||||||
|
if !ok || len(openAIMsgs) != 2 {
|
||||||
|
t.Fatalf("Expected 2 messages (system + user) in OpenAI body, got %+v", receivedOpenAIBody)
|
||||||
|
}
|
||||||
|
if openAIMsgs[0].(map[string]any)["role"] != "system" {
|
||||||
|
t.Errorf("Expected first message to be system prompt")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Streaming test
|
||||||
|
t.Run("Streaming Anthropic message", func(t *testing.T) {
|
||||||
|
reqObj := map[string]any{
|
||||||
|
"model": "claude-model",
|
||||||
|
"stream": true,
|
||||||
|
"messages": []any{
|
||||||
|
map[string]any{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Stream me",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
reqBytes, _ := json.Marshal(reqObj)
|
||||||
|
req := httptest.NewRequest("POST", "/v1/messages", bytes.NewReader(reqBytes))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Result().StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("Expected 200 OK, got %d", w.Result().StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStr := w.Body.String()
|
||||||
|
if !strings.Contains(bodyStr, "event: message_start") || !strings.Contains(bodyStr, "event: content_block_delta") || !strings.Contains(bodyStr, "event: message_stop") {
|
||||||
|
t.Errorf("Expected Anthropic SSE stream events in response, got: %s", bodyStr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenAIResponsesEndpoint(t *testing.T) {
|
||||||
|
var receivedOpenAIBody map[string]any
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/v1/chat/completions" {
|
||||||
|
t.Errorf("Expected request to be routed to /v1/chat/completions, got %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]any
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
receivedOpenAIBody = body
|
||||||
|
|
||||||
|
isStream, _ := body["stream"].(bool)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if isStream {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
flusher, _ := w.(http.Flusher)
|
||||||
|
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"Response \"}}]}\n\n"))
|
||||||
|
flusher.Flush()
|
||||||
|
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"Chunk\"},\"finish_reason\":\"stop\"}]}\n\n"))
|
||||||
|
flusher.Flush()
|
||||||
|
_, _ = w.Write([]byte("data: [DONE]\n\n"))
|
||||||
|
flusher.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
resp := map[string]any{
|
||||||
|
"id": "chatcmpl-resp-test",
|
||||||
|
"model": "gpt-5-model",
|
||||||
|
"choices": []any{
|
||||||
|
map[string]any{
|
||||||
|
"index": 0,
|
||||||
|
"message": map[string]any{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "Hello from Responses proxy",
|
||||||
|
},
|
||||||
|
"finish_reason": "stop",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"usage": map[string]any{
|
||||||
|
"prompt_tokens": 12,
|
||||||
|
"completion_tokens": 18,
|
||||||
|
"total_tokens": 30,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
configs := []ModelConfig{
|
||||||
|
{Model: "gpt-5-model", Key: "test-key", Endpoint: server.URL},
|
||||||
|
}
|
||||||
|
cm := &ConfigManager{
|
||||||
|
configs: configs,
|
||||||
|
uniqueModels: []string{"gpt-5-model"},
|
||||||
|
}
|
||||||
|
handler := handleResponses(cm, nil, 0)
|
||||||
|
|
||||||
|
// 1. Non-streaming test
|
||||||
|
t.Run("Non-streaming OpenAI response", func(t *testing.T) {
|
||||||
|
reqObj := map[string]any{
|
||||||
|
"model": "gpt-5-model",
|
||||||
|
"instructions": "Be accurate",
|
||||||
|
"input": "What is 2+2?",
|
||||||
|
}
|
||||||
|
reqBytes, _ := json.Marshal(reqObj)
|
||||||
|
req := httptest.NewRequest("POST", "/v1/responses", bytes.NewReader(reqBytes))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Result().StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("Expected 200 OK, got %d", w.Result().StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var respObj map[string]any
|
||||||
|
_ = json.NewDecoder(w.Body).Decode(&respObj)
|
||||||
|
|
||||||
|
if respObj["object"] != "response" {
|
||||||
|
t.Errorf("Expected object 'response', got %v", respObj["object"])
|
||||||
|
}
|
||||||
|
if respObj["status"] != "completed" {
|
||||||
|
t.Errorf("Expected status 'completed', got %v", respObj["status"])
|
||||||
|
}
|
||||||
|
output, ok := respObj["output"].([]any)
|
||||||
|
if !ok || len(output) == 0 {
|
||||||
|
t.Fatalf("Expected output array in Responses API response, got %+v", respObj)
|
||||||
|
}
|
||||||
|
firstOutput := output[0].(map[string]any)
|
||||||
|
contentSlice, ok := firstOutput["content"].([]any)
|
||||||
|
if !ok || len(contentSlice) == 0 {
|
||||||
|
t.Fatalf("Expected output message content slice, got %+v", firstOutput)
|
||||||
|
}
|
||||||
|
textVal := contentSlice[0].(map[string]any)["text"].(string)
|
||||||
|
if textVal != "Hello from Responses proxy" {
|
||||||
|
t.Errorf("Unexpected text in response output: %s", textVal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify instructions and input were parsed into OpenAI messages format
|
||||||
|
openAIMsgs, ok := receivedOpenAIBody["messages"].([]any)
|
||||||
|
if !ok || len(openAIMsgs) != 2 {
|
||||||
|
t.Fatalf("Expected 2 messages in OpenAI body, got %+v", receivedOpenAIBody)
|
||||||
|
}
|
||||||
|
if openAIMsgs[0].(map[string]any)["role"] != "system" {
|
||||||
|
t.Errorf("Expected system role for instructions")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Streaming test
|
||||||
|
t.Run("Streaming OpenAI response", func(t *testing.T) {
|
||||||
|
reqObj := map[string]any{
|
||||||
|
"model": "gpt-5-model",
|
||||||
|
"stream": true,
|
||||||
|
"input": "Stream test",
|
||||||
|
}
|
||||||
|
reqBytes, _ := json.Marshal(reqObj)
|
||||||
|
req := httptest.NewRequest("POST", "/v1/responses", bytes.NewReader(reqBytes))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Result().StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("Expected 200 OK, got %d", w.Result().StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyStr := w.Body.String()
|
||||||
|
if !strings.Contains(bodyStr, "event: response.created") || !strings.Contains(bodyStr, "event: response.output_text.delta") || !strings.Contains(bodyStr, "event: response.completed") {
|
||||||
|
t.Errorf("Expected OpenAI Responses SSE events in response stream, got: %s", bodyStr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1063
File diff suppressed because it is too large
Load Diff
@@ -77,8 +77,15 @@ func main() {
|
|||||||
// Register routes
|
// Register routes
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/v1/models", handleModels(cm, authTokens))
|
mux.HandleFunc("/v1/models", handleModels(cm, authTokens))
|
||||||
|
mux.HandleFunc("/models", handleModels(cm, authTokens))
|
||||||
mux.HandleFunc("/v1/chat/completions", handleChatCompletions(cm, authTokens, retryDelay))
|
mux.HandleFunc("/v1/chat/completions", handleChatCompletions(cm, authTokens, retryDelay))
|
||||||
|
mux.HandleFunc("/chat/completions", handleChatCompletions(cm, authTokens, retryDelay))
|
||||||
mux.HandleFunc("/v1/images/generations", handleImageGenerations(cm, authTokens, retryDelay))
|
mux.HandleFunc("/v1/images/generations", handleImageGenerations(cm, authTokens, retryDelay))
|
||||||
|
mux.HandleFunc("/images/generations", handleImageGenerations(cm, authTokens, retryDelay))
|
||||||
|
mux.HandleFunc("/v1/messages", handleMessages(cm, authTokens, retryDelay))
|
||||||
|
mux.HandleFunc("/messages", handleMessages(cm, authTokens, retryDelay))
|
||||||
|
mux.HandleFunc("/v1/responses", handleResponses(cm, authTokens, retryDelay))
|
||||||
|
mux.HandleFunc("/responses", handleResponses(cm, authTokens, retryDelay))
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: fmt.Sprintf(":%d", *port),
|
Addr: fmt.Sprintf(":%d", *port),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ inclusionai/ling-3.0-flash:free,-,https://api.kilo.ai/api/openrouter,
|
|||||||
kilo-auto/free,-,https://api.kilo.ai/api/openrouter,
|
kilo-auto/free,-,https://api.kilo.ai/api/openrouter,
|
||||||
laguna-s-2.1-free,-,https://opencode.ai/zen,
|
laguna-s-2.1-free,-,https://opencode.ai/zen,
|
||||||
ling-3.0-flash-free,-,https://opencode.ai/zen,
|
ling-3.0-flash-free,-,https://opencode.ai/zen,
|
||||||
|
longcat-2.0-free,-,https://opencode.ai/zen,
|
||||||
Meta-Llama-3_3-70B-Instruct,-,https://oai.endpoints.kepler.ai.cloud.ovh.net,
|
Meta-Llama-3_3-70B-Instruct,-,https://oai.endpoints.kepler.ai.cloud.ovh.net,
|
||||||
mimo-v2.5-free,-,https://opencode.ai/zen,
|
mimo-v2.5-free,-,https://opencode.ai/zen,
|
||||||
Mistral-7B-Instruct-v0.3,-,https://oai.endpoints.kepler.ai.cloud.ovh.net,
|
Mistral-7B-Instruct-v0.3,-,https://oai.endpoints.kepler.ai.cloud.ovh.net,
|
||||||
|
|||||||
|
Reference in New Issue
Block a user