feat(composite): support Codex endpoints

This commit is contained in:
Kingsley
2026-08-19 04:40:32 +00:00
parent 82f7dd14f7
commit 58e147fba6
12 changed files with 216 additions and 28 deletions
@@ -30,8 +30,8 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
return
}
if apiKey.Group.Platform != service.PlatformOpenAI {
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search is only available for OpenAI groups")
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search is only available for OpenAI and Composite groups")
return
}
subject, ok := middleware2.GetAuthSubjectFromContext(c)
@@ -75,6 +75,10 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
return
}
requestedModel := strings.TrimSpace(modelResult.String())
if !compositeTargetPlatformAllowed(c, apiKey, requestedModel, service.PlatformOpenAI) {
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex alpha search only supports OpenAI models for Composite groups")
return
}
reqLog = reqLog.With(zap.String("model", requestedModel))
setOpsRequestContext(c, requestedModel, false)
setOpsEndpointContext(c, "", int16(service.RequestTypeSync))
@@ -27,8 +27,8 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
h.errorResponse(c, http.StatusUnauthorized, "invalid_request_error", "API key group is required")
return
}
if apiKey.Group.Platform != service.PlatformOpenAI {
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex models manifest is only available for OpenAI groups")
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Codex models manifest is only available for OpenAI and Composite groups")
return
}
@@ -116,6 +116,19 @@ func TestCodexModelsCanceledRequestDoesNotWriteResponse(t *testing.T) {
}
}
func TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) {
handler, upstream, groupID := newCodexModelsFailoverTestHandler(http.StatusServiceUnavailable)
recorder := performCodexModelsRequestForPlatform(t, handler, groupID, service.PlatformComposite)
if got, want := upstream.calls(), []int64{1, 2}; !equalInt64Slices(got, want) {
t.Fatalf("upstream account calls: got %v, want %v", got, want)
}
if recorder.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
}
func TestCodexModelsFailsOverFromRetryableUpstreamStatus(t *testing.T) {
retryableStatuses := []int{
http.StatusTooManyRequests,
@@ -287,13 +300,17 @@ func newCodexModelsFailoverTestHandlerWithAccountCount(firstStatus, accountCount
}
func performCodexModelsRequest(t *testing.T, handler *OpenAIGatewayHandler, groupID int64) *httptest.ResponseRecorder {
return performCodexModelsRequestForPlatform(t, handler, groupID, service.PlatformOpenAI)
}
func performCodexModelsRequestForPlatform(t *testing.T, handler *OpenAIGatewayHandler, groupID int64, platform string) *httptest.ResponseRecorder {
t.Helper()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models?client_version=0.144.0", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI},
Group: &service.Group{ID: groupID, Platform: platform},
})
handler.CodexModels(c)
+15 -2
View File
@@ -15,6 +15,7 @@ import (
coderws "github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"go.uber.org/zap"
)
@@ -29,7 +30,7 @@ func (h *OpenAIGatewayHandler) Live(c *gin.Context) {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
return
}
if apiKey.Group == nil || apiKey.Group.Platform != service.PlatformOpenAI {
if apiKey.Group == nil || (apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite) {
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Live is not supported for this platform")
return
}
@@ -43,6 +44,18 @@ func (h *OpenAIGatewayHandler) Live(c *gin.Context) {
return
}
model := strings.TrimSpace(gjson.GetBytes(request.Session, "model").String())
if !compositeTargetPlatformAllowed(c, apiKey, model, service.PlatformOpenAI) {
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Live only supports OpenAI models for Composite groups")
return
}
if upstreamModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && upstreamModel != model {
rewrittenSession, rewriteErr := sjson.SetBytes(request.Session, "model", upstreamModel)
if rewriteErr != nil {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to apply Composite model route")
return
}
request.Session = rewrittenSession
}
reqLog := requestLogger(
c,
"handler.openai_gateway.live",
@@ -231,6 +244,6 @@ func (h *OpenAIGatewayHandler) LiveSideband(c *gin.Context) {
func liveEnabledForAPIKey(apiKey *service.APIKey) bool {
return apiKey != nil &&
apiKey.Group != nil &&
apiKey.Group.Platform == service.PlatformOpenAI &&
(apiKey.Group.Platform == service.PlatformOpenAI || apiKey.Group.Platform == service.PlatformComposite) &&
apiKey.Group.AllowLive
}
@@ -87,6 +87,9 @@ func TestLiveEnabledForAPIKey(t *testing.T) {
require.True(t, liveEnabledForAPIKey(&service.APIKey{
Group: &service.Group{Platform: service.PlatformOpenAI, AllowLive: true},
}))
require.True(t, liveEnabledForAPIKey(&service.APIKey{
Group: &service.Group{Platform: service.PlatformComposite, AllowLive: true},
}))
}
func TestLiveAttestationErrorIsExplicit(t *testing.T) {
@@ -133,6 +133,78 @@ func TestCompositeTargetPlatformMiddlewareUsesExplicitRouteAndRewritesBody(t *te
require.Equal(t, http.StatusNoContent, w.Code)
}
func TestCompositeTargetPlatformMiddlewareRewritesNestedLiveModel(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
resolver := service.NewCompositeRouteResolver(compositeRouteRepoStub{
routes: []service.CompositeModelRoute{
{
ID: 1,
GroupID: 1,
PublicModel: "live-alias",
MatchType: service.CompositeRouteMatchExact,
TargetPlatform: service.PlatformOpenAI,
UpstreamModel: "gpt-live",
Endpoint: service.CompositeRouteEndpointAny,
Priority: 100,
Enabled: true,
},
},
})
router.Use(gin.HandlerFunc(servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) {
groupID := int64(1)
c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{
GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
c.Next()
})))
router.Use(compositeTargetPlatformMiddleware(resolver))
router.POST("/backend-api/codex/realtime/calls", func(c *gin.Context) {
platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.True(t, ok)
require.Equal(t, service.PlatformOpenAI, platform)
body, err := io.ReadAll(c.Request.Body)
require.NoError(t, err)
require.JSONEq(t, `{"session":{"model":"gpt-live"},"sdp":"v=0"}`, string(body))
c.Status(http.StatusNoContent)
})
req := httptest.NewRequest(
http.MethodPost,
"/backend-api/codex/realtime/calls",
strings.NewReader(`{"session":{"model":"live-alias"},"sdp":"v=0"}`),
)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNoContent, w.Code)
}
func TestCompositeRequestModelFromMultipartLiveSession(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
require.NoError(t, writer.WriteField("sdp", "v=0"))
require.NoError(t, writer.WriteField("session", `{"model":"live-alias"}`))
require.NoError(t, writer.Close())
require.Equal(t, "live-alias", compositeRequestModelFromBody(writer.FormDataContentType(), body.Bytes()))
}
func TestCompositeCodexControlPathsUseResponsesRoutes(t *testing.T) {
for _, path := range []string{
"/v1/alpha/search",
"/backend-api/codex/alpha/search",
"/v1/live",
"/backend-api/codex/realtime/calls",
} {
require.Equal(t, service.CompositeRouteEndpointResponses, compositeRouteEndpointForPath(path), "path=%s", path)
}
}
func TestCompositeTargetPlatformMiddlewareUsesExplicitRouteForMultipartImages(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
+38 -12
View File
@@ -55,9 +55,6 @@ func RegisterGatewayRoutes(
return false
}
}
isOpenAIGatewayPlatform := func(c *gin.Context) bool {
return getGroupPlatform(c) == service.PlatformOpenAI
}
countTokensHandler := func(c *gin.Context) {
switch getGroupPlatform(c) {
case service.PlatformOpenAI, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek:
@@ -69,9 +66,12 @@ func RegisterGatewayRoutes(
}
}
modelsHandler := func(c *gin.Context) {
if isOpenAIGatewayPlatform(c) && c.Query("client_version") != "" {
h.OpenAIGateway.CodexModels(c)
return
if c.Query("client_version") != "" {
switch getGroupPlatform(c) {
case service.PlatformOpenAI, service.PlatformComposite:
h.OpenAIGateway.CodexModels(c)
return
}
}
h.Gateway.Models(c)
}
@@ -551,8 +551,10 @@ func compositeTargetPlatformMiddleware(resolver *service.CompositeRouteResolver)
if decision.Matched {
c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision))
if upstreamModel := strings.TrimSpace(decision.UpstreamModel); upstreamModel != "" && upstreamModel != model && gjson.ValidBytes(body) {
if rewritten, rewriteErr := sjson.SetBytes(body, "model", upstreamModel); rewriteErr == nil {
body = rewritten
if _, modelPath := compositeJSONRequestModel(body); modelPath != "" {
if rewritten, rewriteErr := sjson.SetBytes(body, modelPath, upstreamModel); rewriteErr == nil {
body = rewritten
}
}
}
}
@@ -563,12 +565,25 @@ func compositeTargetPlatformMiddleware(resolver *service.CompositeRouteResolver)
}
func compositeRequestModelFromBody(contentType string, body []byte) string {
if model := strings.TrimSpace(gjson.GetBytes(body, "model").String()); model != "" {
if model, _ := compositeJSONRequestModel(body); model != "" {
return model
}
return compositeMultipartModelFromBody(contentType, body)
}
func compositeJSONRequestModel(body []byte) (string, string) {
for _, path := range []string{"model", "session.model"} {
model := gjson.GetBytes(body, path)
if model.Type != gjson.String {
continue
}
if value := strings.TrimSpace(model.String()); value != "" {
return value, path
}
}
return "", ""
}
func compositeMultipartModelFromBody(contentType string, body []byte) string {
mediaType, params, err := mime.ParseMediaType(strings.TrimSpace(contentType))
if err != nil || !strings.EqualFold(mediaType, "multipart/form-data") {
@@ -587,14 +602,22 @@ func compositeMultipartModelFromBody(contentType string, body []byte) string {
if err != nil {
return ""
}
if part.FormName() != "model" || part.FileName() != "" {
fieldName := part.FormName()
if part.FileName() != "" || (fieldName != "model" && fieldName != "session") {
continue
}
data, err := io.ReadAll(part)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
switch fieldName {
case "model":
return strings.TrimSpace(string(data))
case "session":
if model, _ := compositeJSONRequestModel(data); model != "" {
return model
}
}
}
}
@@ -670,7 +693,10 @@ func compositeRouteEndpointForPath(path string) string {
return service.CompositeRouteEndpointCountTokens
case strings.Contains(path, "/messages"):
return service.CompositeRouteEndpointMessages
case strings.Contains(path, "/responses"):
case strings.Contains(path, "/responses"),
strings.Contains(path, "/alpha/search"),
strings.Contains(path, "/realtime/calls"),
strings.HasSuffix(strings.TrimRight(path, "/"), "/live"):
return service.CompositeRouteEndpointResponses
case strings.Contains(path, "/chat/completions"):
return service.CompositeRouteEndpointChatCompletions
@@ -94,7 +94,7 @@ func TestGatewayRoutesOpenAIAlphaSearchPathsAreRegistered(t *testing.T) {
}
}
func TestGatewayRoutesAlphaSearchRejectsNonOpenAIGroup(t *testing.T) {
func TestGatewayRoutesAlphaSearchRejectsUnsupportedGroup(t *testing.T) {
router := newGatewayRoutesTestRouter(service.PlatformGrok)
req := httptest.NewRequest(http.MethodPost, "/v1/alpha/search", strings.NewReader(`{"model":"gpt-5.6-sol"}`))
req.Header.Set("Content-Type", "application/json")
@@ -103,7 +103,7 @@ func TestGatewayRoutesAlphaSearchRejectsNonOpenAIGroup(t *testing.T) {
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code)
require.Contains(t, w.Body.String(), "only available for OpenAI groups")
require.Contains(t, w.Body.String(), "only available for OpenAI and Composite groups")
}
func TestGatewayRoutesOpenAIImagesPathsAreRegistered(t *testing.T) {
+2 -2
View File
@@ -510,7 +510,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
ReasoningEffortMappings: reasoningEffortMappings,
}
sanitizeGroupMessagesDispatchFields(group)
if group.Platform != PlatformOpenAI {
if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite {
group.AllowLive = false
}
sanitizeGroupReasoningEffortPolicy(group)
@@ -890,7 +890,7 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
group.ReasoningEffortMappings = reasoningEffortMappings
}
sanitizeGroupMessagesDispatchFields(group)
if group.Platform != PlatformOpenAI {
if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite {
group.AllowLive = false
}
sanitizeGroupReasoningEffortPolicy(group)
@@ -1029,6 +1029,44 @@ func TestAdminService_CreateGroup_ClearsMessagesDispatchFieldsForNonOpenAIPlatfo
require.Equal(t, OpenAIMessagesDispatchModelConfig{}, repo.created.MessagesDispatchModelConfig)
}
func TestAdminService_CreateCompositeGroupPreservesLive(t *testing.T) {
repo := &groupRepoStubForAdmin{}
svc := &adminServiceImpl{groupRepo: repo}
group, err := svc.CreateGroup(context.Background(), &CreateGroupInput{
Name: "composite-group",
Platform: PlatformComposite,
RateMultiplier: 1.0,
AllowLive: true,
})
require.NoError(t, err)
require.NotNil(t, group)
require.NotNil(t, repo.created)
require.True(t, repo.created.AllowLive)
}
func TestAdminService_UpdateCompositeGroupPreservesLive(t *testing.T) {
existingGroup := &Group{
ID: 1,
Name: "composite-group",
Platform: PlatformComposite,
Status: StatusActive,
}
repo := &groupRepoStubForAdmin{getByID: existingGroup}
svc := &adminServiceImpl{groupRepo: repo}
allowLive := true
group, err := svc.UpdateGroup(context.Background(), existingGroup.ID, &UpdateGroupInput{
AllowLive: &allowLive,
})
require.NoError(t, err)
require.NotNil(t, group)
require.NotNil(t, repo.updated)
require.True(t, repo.updated.AllowLive)
}
func TestAdminService_UpdateGroup_ClearsMessagesDispatchFieldsWhenPlatformChangesAwayFromOpenAI(t *testing.T) {
existingGroup := &Group{
ID: 1,
+6
View File
@@ -53,6 +53,12 @@ route's `upstream_model` before dispatch. For Gemini native paths such as
`/v1beta/models/{model}:generateContent`, the gateway resolves `{model}` and
the handler forwards the resolved upstream model.
Codex Alpha Search and Live requests use the `responses` route domain. Live
requests resolve the model from `session.model`, including multipart `session`
payloads, and apply the configured `upstream_model` before dispatch.
Codex model manifest requests reuse the existing OpenAI account selection and
failover path within the Composite group.
## Built-In Detection
Composite routing detects common public model IDs and provider-prefixed IDs:
+14 -5
View File
@@ -1581,9 +1581,9 @@
</div>
</div>
</div>
<!-- OpenAI Live 开关(仅 openai 平台) -->
<!-- Codex Live 开关(OpenAI 与 Composite 平台) -->
<div
v-if="createForm.platform === 'openai'"
v-if="supportsLivePlatform(createForm.platform)"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
@@ -3303,9 +3303,9 @@
</div>
</div>
</div>
<!-- OpenAI Live 开关(仅 openai 平台) -->
<!-- Codex Live 开关(OpenAI 与 Composite 平台) -->
<div
v-if="editForm.platform === 'openai'"
v-if="supportsLivePlatform(editForm.platform)"
class="border-t border-gray-200 dark:border-dark-400 pt-4 mt-4"
>
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
@@ -4501,6 +4501,9 @@ import {
videoModelPriceFamilyRows,
} from "./groupsVideoModelPricing";
const supportsLivePlatform = (platform: string): boolean =>
platform === "openai" || platform === "composite";
const emptyGroupPricing = (): PricingFormEntry => ({
models: [],
billing_mode: "token",
@@ -6663,6 +6666,8 @@ watch(
}
if (newVal !== "openai") {
resetMessagesDispatchFormState(createForm);
}
if (!supportsLivePlatform(newVal)) {
createForm.allow_live = false;
}
if (!isProfitControlPlatform(newVal)) {
@@ -6711,6 +6716,8 @@ watch(
}
if (newVal !== "openai") {
resetMessagesDispatchFormState(editForm);
}
if (!supportsLivePlatform(newVal)) {
editForm.allow_live = false;
}
if (!isProfitControlPlatform(newVal)) {
@@ -6761,9 +6768,11 @@ watch(
}
if (newVal !== 'openai') {
editForm.allow_messages_dispatch = false
editForm.allow_live = false
editForm.default_mapped_model = ''
}
if (!supportsLivePlatform(newVal)) {
editForm.allow_live = false
}
}
)