Skip to content

Commit baae5fc

Browse files
authored
Merge pull request #452 from phantom5099/main
pref:修复自定义端口未生效问题
2 parents 3b2310a + 4eb15e1 commit baae5fc

7 files changed

Lines changed: 221 additions & 1 deletion

File tree

docs/guides/adding-providers.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ discovery_endpoint_path: /models
105105
106106
- `chat_api_mode` 仅 `openaicompat` 生效,可选值:`chat_completions` / `responses`。
107107
- `chat_endpoint_path` 为 `/` 表示直连 `base_url`;为空时会按 `chat_api_mode` 自动回填默认子路径(`/chat/completions` 或 `/responses`)。
108+
- 当 `chat_api_mode` 已显式指定时,`chat_endpoint_path` 可使用任意以 `/` 开头的相对路径;未显式指定时,仅支持标准端点推断(`/chat/completions`、`/responses`、`/`)。
108109
- `model_source: manual` 时必须提供 `models`,且会忽略 `discovery_endpoint_path`。
109110

110111
## 测试要求

internal/config/state/service_provider_create_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,48 @@ func TestCreateCustomProviderSuccess(t *testing.T) {
6767
}
6868
}
6969

70+
func TestCreateCustomProviderPreservesExplicitCustomChatEndpoint(t *testing.T) {
71+
restorePersist, restoreDelete, restoreLookup, restoreSaveWithModels := stubUserEnvOpsForCreateProvider(t)
72+
defer restorePersist()
73+
defer restoreDelete()
74+
defer restoreLookup()
75+
defer restoreSaveWithModels()
76+
77+
manager := newSelectionTestManager(t, testDefaultConfig())
78+
service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{
79+
listModels: []providertypes.ModelDescriptor{
80+
{ID: "custom-model", Name: "custom-model"},
81+
},
82+
})
83+
84+
input := CreateCustomProviderInput{
85+
Name: "custom-chat-endpoint-provider",
86+
Driver: provider.DriverOpenAICompat,
87+
BaseURL: "https://llm.example.com/v1",
88+
ChatAPIMode: provider.ChatAPIModeChatCompletions,
89+
ChatEndpointPath: "/v1/text/chatcompletion_v2",
90+
APIKeyEnv: "CUSTOM_CHAT_ENDPOINT_PROVIDER_API_KEY",
91+
APIKey: "test-key",
92+
DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels,
93+
}
94+
95+
if _, err := service.CreateCustomProvider(context.Background(), input); err != nil {
96+
t.Fatalf("CreateCustomProvider() error = %v", err)
97+
}
98+
99+
cfg := manager.Get()
100+
providerCfg, err := cfg.ProviderByName(input.Name)
101+
if err != nil {
102+
t.Fatalf("expected provider %q in config, got %v", input.Name, err)
103+
}
104+
if providerCfg.ChatEndpointPath != input.ChatEndpointPath {
105+
t.Fatalf("expected chat endpoint path %q, got %q", input.ChatEndpointPath, providerCfg.ChatEndpointPath)
106+
}
107+
if providerCfg.ChatAPIMode != input.ChatAPIMode {
108+
t.Fatalf("expected chat api mode %q, got %q", input.ChatAPIMode, providerCfg.ChatAPIMode)
109+
}
110+
}
111+
70112
func TestCreateCustomProviderManualSourceRequiresModelJSON(t *testing.T) {
71113
restorePersist, restoreDelete, restoreLookup, restoreSaveWithModels := stubUserEnvOpsForCreateProvider(t)
72114
defer restorePersist()

internal/provider/openaicompat/driver_internal_test.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ func TestDriverClosuresAndSupportedProtocol(t *testing.T) {
6363
}); err != nil || got != executionModeResponses {
6464
t.Fatalf("expected explicit responses execution mode, got mode=%q err=%v", got, err)
6565
}
66+
if got, err := resolveExecutionMode(provider.RuntimeConfig{
67+
Driver: DriverName,
68+
ChatAPIMode: provider.ChatAPIModeChatCompletions,
69+
ChatEndpointPath: "/v1/text/chatcompletion_v2",
70+
}); err != nil || got != executionModeCompletions {
71+
t.Fatalf("expected explicit completions mode with custom path, got mode=%q err=%v", got, err)
72+
}
6673
if got, err := resolveExecutionMode(provider.RuntimeConfig{
6774
Driver: DriverName,
6875
ChatAPIMode: provider.ChatAPIModeResponses,
@@ -82,6 +89,12 @@ func TestDriverClosuresAndSupportedProtocol(t *testing.T) {
8289
}); err != nil || got != executionModeResponses {
8390
t.Fatalf("expected endpoint inferred responses mode, got mode=%q err=%v", got, err)
8491
}
92+
if _, err := resolveExecutionMode(provider.RuntimeConfig{
93+
Driver: DriverName,
94+
ChatEndpointPath: "/v1/text/chatcompletion_v2",
95+
}); err == nil || !strings.Contains(err.Error(), "set chat_api_mode") {
96+
t.Fatalf("expected custom path to require explicit chat_api_mode, got %v", err)
97+
}
8598
if _, err := resolveExecutionMode(provider.RuntimeConfig{Driver: provider.DriverAnthropic}); err == nil ||
8699
!strings.Contains(err.Error(), "unsupported") {
87100
t.Fatalf("expected unsupported anthropic driver error, got %v", err)

internal/provider/openaicompat/generate_sdk.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ func (p *Provider) generateSDKChatCompletions(
2424
payload chatcompletions.Request,
2525
events chan<- providertypes.StreamEvent,
2626
) error {
27+
if shouldUseCompatibleChatCompletionsEndpoint(p.cfg) {
28+
return p.generateChatCompletionsWithCompatibleStream(ctx, payload, events)
29+
}
30+
2731
client, err := p.newSDKClient()
2832
if err != nil {
2933
return err
@@ -272,6 +276,24 @@ func (p *Provider) generateChatCompletionsWithCompatibleStream(
272276
return chatcompletions.ConsumeStream(ctx, resp.Body, events)
273277
}
274278

279+
// shouldUseCompatibleChatCompletionsEndpoint 判断 chat/completions 是否需要绕过 SDK 默认端点拼接。
280+
func shouldUseCompatibleChatCompletionsEndpoint(cfg provider.RuntimeConfig) bool {
281+
resolvedEndpoint, err := provider.ResolveChatEndpointURL(
282+
cfg.BaseURL,
283+
resolveChatEndpointPathByMode(cfg.ChatEndpointPath, provider.ChatAPIModeChatCompletions),
284+
)
285+
if err != nil {
286+
return false
287+
}
288+
289+
defaultEndpoint, err := provider.ResolveChatEndpointURL(cfg.BaseURL, chatEndpointPathCompletions)
290+
if err != nil {
291+
return false
292+
}
293+
294+
return strings.TrimSpace(resolvedEndpoint) != strings.TrimSpace(defaultEndpoint)
295+
}
296+
275297
// generateSDKResponses 走 SDK responses 发送请求,复用本地流事件映射。
276298
func (p *Provider) generateSDKResponses(
277299
ctx context.Context,

internal/provider/openaicompat/generate_sdk_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010

1111
openai "github.com/openai/openai-go/v3"
1212

13+
"neo-code/internal/provider"
1314
"neo-code/internal/provider/openaicompat/chatcompletions"
1415
)
1516

@@ -54,6 +55,12 @@ func TestResolveChatEndpointPathByMode(t *testing.T) {
5455
mode: "responses",
5556
want: "/responses",
5657
},
58+
{
59+
name: "fills chat completions path for explicit completions mode",
60+
path: "",
61+
mode: "chat_completions",
62+
want: "/chat/completions",
63+
},
5764
}
5865

5966
for _, tt := range tests {
@@ -65,6 +72,116 @@ func TestResolveChatEndpointPathByMode(t *testing.T) {
6572
}
6673
}
6774

75+
func TestResolveChatEndpointUsesExplicitModeFallbackAndCustomPath(t *testing.T) {
76+
t.Parallel()
77+
78+
tests := []struct {
79+
name string
80+
cfg provider.RuntimeConfig
81+
want string
82+
}{
83+
{
84+
name: "explicit responses mode falls back to responses path",
85+
cfg: provider.RuntimeConfig{
86+
BaseURL: "https://api.example.com/v1",
87+
ChatAPIMode: provider.ChatAPIModeResponses,
88+
ChatEndpointPath: "",
89+
},
90+
want: "https://api.example.com/v1/responses",
91+
},
92+
{
93+
name: "explicit completions mode falls back to completions path",
94+
cfg: provider.RuntimeConfig{
95+
BaseURL: "https://api.example.com/v1",
96+
ChatAPIMode: provider.ChatAPIModeChatCompletions,
97+
ChatEndpointPath: "",
98+
},
99+
want: "https://api.example.com/v1/chat/completions",
100+
},
101+
{
102+
name: "custom path stays unchanged when explicit mode is set",
103+
cfg: provider.RuntimeConfig{
104+
BaseURL: "https://api.example.com/v1",
105+
ChatAPIMode: provider.ChatAPIModeChatCompletions,
106+
ChatEndpointPath: "/v1/text/chatcompletion_v2",
107+
},
108+
want: "https://api.example.com/v1/v1/text/chatcompletion_v2",
109+
},
110+
{
111+
name: "slash keeps direct base url mode",
112+
cfg: provider.RuntimeConfig{
113+
BaseURL: "https://api.example.com/v1",
114+
ChatAPIMode: provider.ChatAPIModeResponses,
115+
ChatEndpointPath: "/",
116+
},
117+
want: "https://api.example.com/v1",
118+
},
119+
}
120+
121+
for _, tt := range tests {
122+
t.Run(tt.name, func(t *testing.T) {
123+
got, err := resolveChatEndpoint(tt.cfg)
124+
if err != nil {
125+
t.Fatalf("resolveChatEndpoint() error = %v", err)
126+
}
127+
if got != tt.want {
128+
t.Fatalf("resolveChatEndpoint() = %q, want %q", got, tt.want)
129+
}
130+
})
131+
}
132+
}
133+
134+
func TestShouldUseCompatibleChatCompletionsEndpoint(t *testing.T) {
135+
t.Parallel()
136+
137+
tests := []struct {
138+
name string
139+
cfg provider.RuntimeConfig
140+
want bool
141+
}{
142+
{
143+
name: "default completions endpoint uses sdk path",
144+
cfg: provider.RuntimeConfig{
145+
BaseURL: "https://api.example.com/v1",
146+
ChatEndpointPath: "",
147+
},
148+
want: false,
149+
},
150+
{
151+
name: "explicit default completions endpoint uses sdk path",
152+
cfg: provider.RuntimeConfig{
153+
BaseURL: "https://api.example.com/v1",
154+
ChatEndpointPath: "/chat/completions",
155+
},
156+
want: false,
157+
},
158+
{
159+
name: "custom completions endpoint uses compatible path",
160+
cfg: provider.RuntimeConfig{
161+
BaseURL: "https://api.example.com",
162+
ChatEndpointPath: "/v1/text/chatcompletion_v2",
163+
},
164+
want: true,
165+
},
166+
{
167+
name: "direct base url mode uses compatible path",
168+
cfg: provider.RuntimeConfig{
169+
BaseURL: "https://api.example.com/v1/text/chatcompletion_v2",
170+
ChatEndpointPath: "/",
171+
},
172+
want: true,
173+
},
174+
}
175+
176+
for _, tt := range tests {
177+
t.Run(tt.name, func(t *testing.T) {
178+
if got := shouldUseCompatibleChatCompletionsEndpoint(tt.cfg); got != tt.want {
179+
t.Fatalf("shouldUseCompatibleChatCompletionsEndpoint() = %v, want %v", got, tt.want)
180+
}
181+
})
182+
}
183+
}
184+
68185
func TestConvertToSDKMessageMapsToolRoleAndAssistantToolCalls(t *testing.T) {
69186
t.Parallel()
70187

internal/provider/openaicompat/provider.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,10 @@ func resolveExecutionMode(cfg provider.RuntimeConfig) (string, error) {
265265
return provider.DefaultProviderChatAPIMode(), nil
266266
default:
267267
return "", provider.NewDiscoveryConfigError(
268-
fmt.Sprintf("openaicompat provider: unsupported chat endpoint path %q", normalizedPath),
268+
fmt.Sprintf(
269+
"openaicompat provider: unsupported chat endpoint path %q without explicit chat_api_mode; set chat_api_mode to chat_completions or responses",
270+
normalizedPath,
271+
),
269272
)
270273
}
271274
}

internal/tui/core/app/update_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2857,6 +2857,28 @@ func TestBuildProviderAddRequest(t *testing.T) {
28572857
}
28582858
})
28592859

2860+
t.Run("openai compat keeps explicit custom endpoint path", func(t *testing.T) {
2861+
req, err := buildProviderAddRequest(providerAddFormState{
2862+
Name: "openai-compat-custom-endpoint",
2863+
Driver: provider.DriverOpenAICompat,
2864+
ModelSource: config.ModelSourceDiscover,
2865+
ChatAPIMode: provider.ChatAPIModeChatCompletions,
2866+
ChatEndpointPath: "/v1/text/chatcompletion_v2",
2867+
APIKey: "k",
2868+
APIKeyEnv: "OPENAI_COMPAT_CUSTOM_ENDPOINT_API_KEY",
2869+
DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels,
2870+
})
2871+
if err != "" {
2872+
t.Fatalf("unexpected error: %s", err)
2873+
}
2874+
if req.ChatEndpointPath != "/v1/text/chatcompletion_v2" {
2875+
t.Fatalf("expected custom endpoint path preserved, got %q", req.ChatEndpointPath)
2876+
}
2877+
if req.ChatAPIMode != provider.ChatAPIModeChatCompletions {
2878+
t.Fatalf("expected chat api mode completions, got %q", req.ChatAPIMode)
2879+
}
2880+
})
2881+
28602882
t.Run("strips control chars from env key before validation", func(t *testing.T) {
28612883
req, err := buildProviderAddRequest(providerAddFormState{
28622884
Name: "openai-compat",

0 commit comments

Comments
 (0)