Skip to content

Commit 3cdab4d

Browse files
authored
feat(ai-proxy): add support for OpenAI Responses API (#6513)
- Add new route and filters for handling Responses API requests- Implement context filter to extract user prompts from API input - Update request path constants to include new Responses API endpoint- Add unit tests for new context filter functionality
1 parent ebe1562 commit 3cdab4d

File tree

8 files changed

+333
-8
lines changed

8 files changed

+333
-8
lines changed

cmd/ai-proxy/bootstrap.yml

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ erda.app.ai-proxy:
88
routes_refs:
99
- conf/routes/routes.yml
1010
- conf/routes/assistant.yml
11+
- conf/routes/responses.yml
1112
- conf/routes/file.yml
1213
- conf/routes/openai_format.yml
1314
- conf/routes/internal_apis.yml
+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
routes:
2+
- path: /v1/responses
3+
method: POST
4+
filters:
5+
- name: initialize
6+
- name: log-http
7+
- name: rate-limit
8+
- name: context
9+
- name: context-responses
10+
- name: audit-before-llm-director
11+
- name: azure-director
12+
config:
13+
directors:
14+
- TransAuthorization
15+
- SetModelAPIVersionIfNotSpecified
16+
- DefaultQueries("api-version=2025-03-01-preview")
17+
- RewriteScheme
18+
- RewriteHost
19+
- RewritePath("/openai/responses")
20+
- RewriteBodyModelName
21+
- ResetContentLength
22+
- name: audit-after-llm-director
23+
- name: finalize
24+
25+
- path: /v1/responses/{response_id}
26+
method: GET
27+
filters:
28+
- name: context
29+
- name: azure-director
30+
config:
31+
directors:
32+
- TransAuthorization
33+
- DefaultQueries("api-version=2025-03-01-preview")
34+
- RewriteScheme
35+
- RewriteHost
36+
- RewritePath("/openai/responses/${ path.response_id }")
37+
38+
- path: /v1/responses/{response_id}
39+
method: DELETE
40+
filters:
41+
- name: context
42+
- name: azure-director
43+
config:
44+
directors:
45+
- TransAuthorization
46+
- DefaultQueries("api-version=2025-03-01-preview")
47+
- RewriteScheme
48+
- RewriteHost
49+
- RewritePath("/openai/responses/${ path.response_id }")
50+
51+
- path: /v1/responses/{response_id}/input_items
52+
method: GET
53+
filters:
54+
- name: context
55+
- name: azure-director
56+
config:
57+
directors:
58+
- TransAuthorization
59+
- DefaultQueries("api-version=2025-03-01-preview")
60+
- RewriteScheme
61+
- RewriteHost
62+
- RewritePath("/openai/responses/${ path.response_id }/input_items")

internal/apps/ai-proxy/common/request_path.go

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const (
3131
RequestPathPrefixV1Files = "/v1/files"
3232

3333
RequestPathPrefixV1Assistants = "/v1/assistants"
34+
RequestPathPrefixV1Responses = "/v1/responses"
3435
RequestPathPrefixV1Threads = "/v1/threads"
3536
RequestPathV1ThreadCreateMessage = "/v1/threads/{thread_id}/messages"
3637
)

internal/apps/ai-proxy/dependent_filters.go

+1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
_ "github.com/erda-project/erda/internal/apps/ai-proxy/filters/context-embedding"
2929
_ "github.com/erda-project/erda/internal/apps/ai-proxy/filters/context-file"
3030
_ "github.com/erda-project/erda/internal/apps/ai-proxy/filters/context-image"
31+
_ "github.com/erda-project/erda/internal/apps/ai-proxy/filters/context-responses"
3132
_ "github.com/erda-project/erda/internal/apps/ai-proxy/filters/dashscope-director"
3233
_ "github.com/erda-project/erda/internal/apps/ai-proxy/filters/erda-auth"
3334
_ "github.com/erda-project/erda/internal/apps/ai-proxy/filters/finalize"

internal/apps/ai-proxy/filters/azure-director/filter.go

+8
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,14 @@ func (f *AzureDirector) RewriteBodyModelName(ctx context.Context) error {
336336
return nil
337337
}
338338

339+
func (f *AzureDirector) ResetContentLength(ctx context.Context) error {
340+
reverseproxy.AppendDirectors(ctx, func(req *http.Request) {
341+
req.Header.Del("Content-Length")
342+
req.ContentLength = -1
343+
})
344+
return nil
345+
}
346+
339347
func (f *AzureDirector) AllDirectors() map[string]func(ctx context.Context) error {
340348
if len(f.funcs) > 0 {
341349
return f.funcs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) 2021 Terminus, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package context
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"net/http"
21+
"strings"
22+
23+
"sigs.k8s.io/yaml"
24+
25+
"github.com/erda-project/erda/internal/apps/ai-proxy/common"
26+
"github.com/erda-project/erda/internal/apps/ai-proxy/common/ctxhelper"
27+
"github.com/erda-project/erda/pkg/reverseproxy"
28+
)
29+
30+
// ContentType represents the type of content in the message
31+
type ContentType string
32+
33+
const (
34+
Name = "context-responses"
35+
ContentTypeInputText ContentType = "input_text"
36+
)
37+
38+
var (
39+
_ reverseproxy.RequestFilter = (*Context)(nil)
40+
)
41+
42+
func init() {
43+
reverseproxy.RegisterFilterCreator(Name, New)
44+
}
45+
46+
type (
47+
// Message represents a message in the conversation
48+
Message struct {
49+
Role string `json:"role"`
50+
Content any `json:"content"`
51+
}
52+
// ContentObject represents a content object in the message
53+
ContentObject struct {
54+
Type string `json:"type"`
55+
Text string `json:"text"`
56+
}
57+
)
58+
59+
type Context struct {
60+
Config *Config
61+
}
62+
63+
type Config struct {
64+
}
65+
66+
func New(configJSON json.RawMessage) (reverseproxy.Filter, error) {
67+
var cfg Config
68+
if err := yaml.Unmarshal(configJSON, &cfg); err != nil {
69+
return nil, err
70+
}
71+
return &Context{Config: &cfg}, nil
72+
}
73+
74+
func (f *Context) OnRequest(ctx context.Context, w http.ResponseWriter, infor reverseproxy.HttpInfor) (signal reverseproxy.Signal, err error) {
75+
if common.GetRequestRoutePath(ctx) == common.RequestPathPrefixV1Responses && infor.Method() == http.MethodPost {
76+
var req map[string]any
77+
if err := json.NewDecoder(infor.BodyBuffer()).Decode(&req); err != nil {
78+
return reverseproxy.Intercept, err
79+
}
80+
81+
prompts := make([]string, 0)
82+
83+
if instructions, ok := req["instructions"].(string); ok && strings.TrimSpace(instructions) != "" {
84+
prompts = append(prompts, instructions)
85+
}
86+
87+
// Handle OpenAI Responses API input structure
88+
if input, ok := req["input"]; ok {
89+
prompts = append(prompts, FindUserPrompts(input)...)
90+
}
91+
92+
ctxhelper.PutUserPrompt(ctx, strings.Join(prompts, "\n"))
93+
infor.SetBody2(req)
94+
}
95+
96+
return reverseproxy.Continue, nil
97+
}
98+
99+
// FindUserPrompts recursively extracts user prompts from OpenAI Responses API structure
100+
func FindUserPrompts(obj any) []string {
101+
if obj == nil {
102+
return nil
103+
}
104+
105+
prompts := make([]string, 0)
106+
switch v := obj.(type) {
107+
case string:
108+
if strings.TrimSpace(v) != "" {
109+
prompts = append(prompts, v)
110+
}
111+
case []any:
112+
for _, item := range v {
113+
if msg, ok := item.(map[string]any); ok {
114+
if role, ok := msg["role"].(string); ok && role == "user" {
115+
if content, ok := msg["content"]; ok {
116+
prompts = append(prompts, extractPromptsFromContent(content)...)
117+
}
118+
}
119+
}
120+
}
121+
}
122+
123+
return prompts
124+
}
125+
126+
// extractPromptsFromContent extracts prompts from content of different types
127+
func extractPromptsFromContent(content any) []string {
128+
prompts := make([]string, 0)
129+
130+
switch c := content.(type) {
131+
case string:
132+
if strings.TrimSpace(c) != "" {
133+
prompts = append(prompts, c)
134+
}
135+
case []any:
136+
for _, cc := range c {
137+
if contentObj, ok := cc.(map[string]any); ok {
138+
if contentType, ok := contentObj["type"].(string); ok && ContentType(contentType) == ContentTypeInputText {
139+
if text, ok := contentObj["text"].(string); ok && text != "" {
140+
prompts = append(prompts, text)
141+
}
142+
}
143+
}
144+
}
145+
}
146+
147+
return prompts
148+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Copyright (c) 2021 Terminus, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package context
16+
17+
import (
18+
"encoding/json"
19+
"testing"
20+
)
21+
22+
func TestFindUserPrompts(t *testing.T) {
23+
testCases := []struct {
24+
name string
25+
input string
26+
expected []string
27+
}{
28+
{
29+
name: "string input",
30+
input: `"hello"`,
31+
expected: []string{"hello"},
32+
},
33+
{
34+
name: "array of user messages",
35+
input: `[
36+
{
37+
"role": "user",
38+
"content": "hello"
39+
},
40+
{
41+
"role": "assistant",
42+
"content": "hi"
43+
},
44+
{
45+
"role": "user",
46+
"content": "how are you"
47+
}
48+
]`,
49+
expected: []string{"hello", "how are you"},
50+
},
51+
{
52+
name: "array with mixed content types",
53+
input: `[
54+
{
55+
"role": "user",
56+
"content": [
57+
{
58+
"type": "input_text",
59+
"text": "text"
60+
},
61+
{
62+
"type": "input_image",
63+
"image_url": "https://www.erda.cloud/_next/image?url=%2Fimages%2Flogo-new.png&w=256&q=75"
64+
},
65+
{
66+
"type": "input_text",
67+
"text": "more text"
68+
}
69+
]
70+
}
71+
]`,
72+
expected: []string{"text", "more text"},
73+
},
74+
{
75+
name: "empty array",
76+
input: `[]`,
77+
expected: []string{},
78+
},
79+
{
80+
name: "null input",
81+
input: `null`,
82+
expected: []string{},
83+
},
84+
}
85+
86+
for _, tc := range testCases {
87+
t.Run(tc.name, func(t *testing.T) {
88+
var input interface{}
89+
if err := json.Unmarshal([]byte(tc.input), &input); err != nil {
90+
t.Fatalf("failed to unmarshal input: %v", err)
91+
}
92+
result := FindUserPrompts(input)
93+
if len(result) != len(tc.expected) {
94+
t.Errorf("expected %d prompts, got %d", len(tc.expected), len(result))
95+
return
96+
}
97+
for i, prompt := range result {
98+
if prompt != tc.expected[i] {
99+
t.Errorf("prompt %d: expected %q, got %q", i, tc.expected[i], prompt)
100+
}
101+
}
102+
})
103+
}
104+
}

internal/apps/ai-proxy/filters/context/filter.go

+8-8
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,6 @@ func (f *Context) OnRequest(ctx context.Context, w http.ResponseWriter, infor re
6868
m = ctx.Value(reverseproxy.CtxKeyMap{}).(*sync.Map)
6969
)
7070

71-
// check body
72-
body := infor.BodyBuffer()
73-
if body == nil {
74-
err = fmt.Errorf("missing body")
75-
l.Error(err)
76-
return reverseproxy.Intercept, err
77-
}
78-
7971
// find client
8072
var client *clientpb.Client
8173
ak := vars.TrimBearer(infor.Header().Get(httputil.HeaderKeyAuthorization))
@@ -144,6 +136,14 @@ func (f *Context) OnRequest(ctx context.Context, w http.ResponseWriter, infor re
144136
type Model struct {
145137
ModelID string `json:"model"`
146138
}
139+
// check body
140+
body := infor.BodyBuffer()
141+
if body == nil {
142+
err = fmt.Errorf("missing body")
143+
l.Error(err)
144+
return reverseproxy.Intercept, err
145+
}
146+
147147
var m Model
148148
if err := json.NewDecoder(body).Decode(&m); err == nil {
149149
if m.ModelID != "" {

0 commit comments

Comments
 (0)