implemented stateless ant/oai messages/responses apis

This commit is contained in:
Luxferre
2026-08-05 08:33:51 +03:00
parent d734a5cdce
commit 274873f9d3
5 changed files with 1387 additions and 1 deletions
+37 -1
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -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),
+1
View File
@@ -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,
1 model key endpoint extra
7 kilo-auto/free - https://api.kilo.ai/api/openrouter
8 laguna-s-2.1-free - https://opencode.ai/zen
9 ling-3.0-flash-free - https://opencode.ai/zen
10 longcat-2.0-free - https://opencode.ai/zen
11 Meta-Llama-3_3-70B-Instruct - https://oai.endpoints.kepler.ai.cloud.ovh.net
12 mimo-v2.5-free - https://opencode.ai/zen
13 Mistral-7B-Instruct-v0.3 - https://oai.endpoints.kepler.ai.cloud.ovh.net