feat: add Responses API compatibility
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
responseChainMaxTTL = 30 * 24 * time.Hour
|
||||
responseChainMaxDepth = 100
|
||||
)
|
||||
|
||||
type responseExecutionContext struct {
|
||||
PublicResponseID string
|
||||
PublicPreviousResponseID string
|
||||
UpstreamPreviousResponseID string
|
||||
Protocol string
|
||||
Store bool
|
||||
PreviousChain *store.ResponseChain
|
||||
PreviousTurns []clients.ResponseTurn
|
||||
ChainDepth int
|
||||
}
|
||||
|
||||
func (s *Service) prepareResponseExecution(ctx context.Context, task store.GatewayTask, body map[string]any) (responseExecutionContext, error) {
|
||||
result := responseExecutionContext{
|
||||
PublicResponseID: gatewayResponseID(task.ID),
|
||||
Store: true,
|
||||
}
|
||||
if value, ok := body["store"].(bool); ok {
|
||||
result.Store = value
|
||||
}
|
||||
previousID := strings.TrimSpace(stringFromAny(body["previous_response_id"]))
|
||||
if previousID == "" {
|
||||
// No Gateway response chain was requested. The caller owns the complete
|
||||
// conversation state in input/messages, so it must be forwarded as-is.
|
||||
return result, nil
|
||||
}
|
||||
chain, err := s.store.GetResponseChain(ctx, previousID, task)
|
||||
if err != nil {
|
||||
return responseExecutionContext{}, &clients.ClientError{
|
||||
Code: "invalid_previous_response_id", Message: "previous_response_id is invalid or expired", StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
if !sameResponseModel(chain.RequestedModel, task.Model) {
|
||||
return responseExecutionContext{}, &clients.ClientError{
|
||||
Code: "invalid_previous_response_id", Message: "previous_response_id belongs to a different model", StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
history, historyErr := s.store.ListResponseChainHistory(ctx, chain)
|
||||
if historyErr != nil {
|
||||
if errors.Is(historyErr, store.ErrResponseChainTooDeep) {
|
||||
return responseExecutionContext{}, &clients.ClientError{Code: "response_chain_too_deep", Message: historyErr.Error(), StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
return responseExecutionContext{}, historyErr
|
||||
}
|
||||
chainDepth, depthErr := responseHistoryDepth(history)
|
||||
if depthErr != nil {
|
||||
return responseExecutionContext{}, depthErr
|
||||
}
|
||||
result.PublicPreviousResponseID = chain.PublicResponseID
|
||||
result.UpstreamPreviousResponseID = chain.UpstreamResponseID
|
||||
result.Protocol = chain.UpstreamProtocol
|
||||
result.PreviousChain = &chain
|
||||
result.ChainDepth = chainDepth
|
||||
if chain.UpstreamProtocol == clients.ProtocolOpenAIChatCompletions {
|
||||
// Chat upstreams are stateless from the Responses API perspective. Only
|
||||
// this Gateway-managed mode replays stored turns; native Responses keeps
|
||||
// the provider's previous_response_id state authoritative.
|
||||
result.PreviousTurns = make([]clients.ResponseTurn, 0, len(history))
|
||||
for _, turn := range history {
|
||||
result.PreviousTurns = append(result.PreviousTurns, clients.ResponseTurn{
|
||||
Request: turn.RequestSnapshot, Response: turn.ResponseSnapshot, Internal: turn.InternalSnapshot,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func prepareResponseCandidates(candidates []store.RuntimeModelCandidate, execution responseExecutionContext) ([]store.RuntimeModelCandidate, error) {
|
||||
if execution.PreviousChain != nil {
|
||||
for _, candidate := range candidates {
|
||||
if candidate.PlatformModelID != execution.PreviousChain.PlatformModelID {
|
||||
continue
|
||||
}
|
||||
if !candidateSupportsProtocol(candidate, execution.PreviousChain.UpstreamProtocol) {
|
||||
break
|
||||
}
|
||||
candidate.ResponseProtocol = execution.PreviousChain.UpstreamProtocol
|
||||
return []store.RuntimeModelCandidate{candidate}, nil
|
||||
}
|
||||
return nil, responseChainUnavailableError()
|
||||
}
|
||||
|
||||
type indexedCandidate struct {
|
||||
candidate store.RuntimeModelCandidate
|
||||
index int
|
||||
group int
|
||||
}
|
||||
items := make([]indexedCandidate, 0, len(candidates))
|
||||
hasAnthropicOnly := false
|
||||
hasDeclaredUnsupported := false
|
||||
for index, candidate := range candidates {
|
||||
protocols := candidateSupportedProtocols(candidate)
|
||||
group := 1
|
||||
protocol := clients.ProtocolOpenAIChatCompletions
|
||||
if containsString(protocols, clients.ProtocolOpenAIResponses) {
|
||||
group = 0
|
||||
protocol = clients.ProtocolOpenAIResponses
|
||||
} else if !containsString(protocols, clients.ProtocolOpenAIChatCompletions) {
|
||||
hasDeclaredUnsupported = true
|
||||
if containsString(protocols, clients.ProtocolAnthropicMessages) {
|
||||
hasAnthropicOnly = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
candidate.ResponseProtocol = protocol
|
||||
items = append(items, indexedCandidate{candidate: candidate, index: index, group: group})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
if hasAnthropicOnly {
|
||||
return nil, &clients.ClientError{Code: "unsupported_model_protocol", Message: "the selected model only supports Anthropic Messages; the public Anthropic adapter is not available", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
if hasDeclaredUnsupported {
|
||||
return nil, &clients.ClientError{Code: "unsupported_model_protocol", Message: "the selected model does not declare an OpenAI-compatible protocol", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
return nil, responseChainUnavailableError()
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].group != items[j].group {
|
||||
return items[i].group < items[j].group
|
||||
}
|
||||
return items[i].index < items[j].index
|
||||
})
|
||||
out := make([]store.RuntimeModelCandidate, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, item.candidate)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func candidateSupportedProtocols(candidate store.RuntimeModelCandidate) []string {
|
||||
capability := effectiveModelCapability(candidate)
|
||||
textCapability, _ := capability["text_generate"].(map[string]any)
|
||||
value, present := textCapability["supportedApiProtocols"]
|
||||
if !present {
|
||||
value, present = capability["supportedApiProtocols"]
|
||||
}
|
||||
if !present {
|
||||
return []string{clients.ProtocolOpenAIChatCompletions}
|
||||
}
|
||||
protocols := stringListFromAny(value)
|
||||
out := make([]string, 0, len(protocols))
|
||||
for _, protocol := range protocols {
|
||||
switch protocol {
|
||||
case clients.ProtocolOpenAIChatCompletions, clients.ProtocolOpenAIResponses, clients.ProtocolAnthropicMessages:
|
||||
out = append(out, protocol)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func candidateSupportsProtocol(candidate store.RuntimeModelCandidate, protocol string) bool {
|
||||
return containsString(candidateSupportedProtocols(candidate), protocol)
|
||||
}
|
||||
|
||||
func responseChainUnavailableError() error {
|
||||
return &clients.ClientError{
|
||||
Code: "response_chain_unavailable", Message: "the platform model pinned to this response chain is unavailable", StatusCode: http.StatusServiceUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
func gatewayResponseID(taskID string) string {
|
||||
return "resp_" + strings.ReplaceAll(strings.TrimSpace(taskID), "-", "")
|
||||
}
|
||||
|
||||
func (s *Service) persistResponseChain(ctx context.Context, task store.GatewayTask, body map[string]any, candidate store.RuntimeModelCandidate, execution responseExecutionContext, response clients.Response) error {
|
||||
if task.Kind != "responses" || !execution.Store {
|
||||
return nil
|
||||
}
|
||||
expiresAt := responseChainExpiry(response.Result, candidate.ResponseProtocol)
|
||||
publicResponseID := strings.TrimSpace(response.PublicResponseID)
|
||||
if publicResponseID == "" {
|
||||
publicResponseID = strings.TrimSpace(stringFromAny(response.Result["id"]))
|
||||
}
|
||||
if publicResponseID == "" {
|
||||
return &clients.ClientError{Code: "invalid_response", Message: "Responses result is missing id", StatusCode: http.StatusBadGateway}
|
||||
}
|
||||
return s.store.CreateResponseChain(ctx, store.CreateResponseChainInput{ResponseChain: store.ResponseChain{
|
||||
PublicResponseID: publicResponseID,
|
||||
ParentResponseID: execution.PublicPreviousResponseID,
|
||||
GatewayTaskID: task.ID,
|
||||
GatewayUserID: task.GatewayUserID,
|
||||
UserID: task.UserID,
|
||||
UserSource: task.UserSource,
|
||||
GatewayTenantID: task.GatewayTenantID,
|
||||
TenantID: task.TenantID,
|
||||
TenantKey: task.TenantKey,
|
||||
APIKeyID: task.APIKeyID,
|
||||
PlatformID: candidate.PlatformID,
|
||||
PlatformModelID: candidate.PlatformModelID,
|
||||
ClientID: candidate.ClientID,
|
||||
UpstreamProtocol: candidate.ResponseProtocol,
|
||||
UpstreamEndpoint: response.UpstreamEndpoint,
|
||||
UpstreamResponseID: response.UpstreamResponseID,
|
||||
RequestSnapshot: cloneMap(body),
|
||||
ResponseSnapshot: cloneMap(response.Result),
|
||||
InternalSnapshot: cloneMap(response.InternalResult),
|
||||
ExpiresAt: expiresAt,
|
||||
}})
|
||||
}
|
||||
|
||||
func sameResponseModel(previous string, current string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(previous), strings.TrimSpace(current))
|
||||
}
|
||||
|
||||
func responseHistoryDepth(history []store.ResponseChain) (int, error) {
|
||||
if len(history) >= responseChainMaxDepth {
|
||||
return 0, &clients.ClientError{Code: "response_chain_too_deep", Message: "response chain exceeds the maximum depth", StatusCode: http.StatusBadRequest}
|
||||
}
|
||||
return len(history), nil
|
||||
}
|
||||
|
||||
func responseChainExpiry(result map[string]any, protocol string) time.Time {
|
||||
now := time.Now()
|
||||
maximum := now.Add(responseChainMaxTTL)
|
||||
if protocol != clients.ProtocolOpenAIResponses {
|
||||
return maximum
|
||||
}
|
||||
var parsed time.Time
|
||||
switch value := result["expire_at"].(type) {
|
||||
case float64:
|
||||
parsed = time.Unix(int64(value), 0)
|
||||
case int64:
|
||||
parsed = time.Unix(value, 0)
|
||||
case string:
|
||||
parsed, _ = time.Parse(time.RFC3339, value)
|
||||
}
|
||||
if parsed.IsZero() || parsed.After(maximum) {
|
||||
return maximum
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func responseExecutionFailure(err error) (string, string) {
|
||||
if errors.Is(err, store.ErrInvalidPreviousResponseID) {
|
||||
return "invalid_previous_response_id", "previous_response_id is invalid or expired"
|
||||
}
|
||||
return clients.ErrorCode(err), err.Error()
|
||||
}
|
||||
Reference in New Issue
Block a user