Skip to content

Commit 82b5798

Browse files
committed
fix(provider): 对齐模型同步请求地址
说明: - 将模型列表 URL 统一从解析后的 Base URL 派生,保持 ! 强制原始地址语义 - 设置页展示模型同步请求,并同步多语言提示文案 操作: - API 路径仍只影响对话/响应端点,不参与 /models 拼接
1 parent cdc0a71 commit 82b5798

18 files changed

Lines changed: 194 additions & 26 deletions

File tree

src-tauri/crates/providers/src/anthropic.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use std::pin::Pin;
88

99
use crate::reasoning::{resolve_reasoning, ReasoningStyle};
1010
use crate::{
11-
build_http_client, parse_base64_data_url, resolve_chat_url, ProviderAdapter,
11+
build_http_client, parse_base64_data_url, resolve_chat_url, resolve_models_url, ProviderAdapter,
1212
ProviderRequestContext,
1313
};
1414

@@ -786,7 +786,7 @@ impl ProviderAdapter for AnthropicAdapter {
786786
}
787787

788788
async fn list_models(&self, ctx: &ProviderRequestContext) -> Result<Vec<Model>> {
789-
let url = format!("{}/models", Self::base_url(ctx));
789+
let url = resolve_models_url(&Self::base_url(ctx));
790790

791791
let resp = crate::apply_request_headers(
792792
self.get_client(ctx)?

src-tauri/crates/providers/src/gemini.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -718,7 +718,7 @@ impl ProviderAdapter for GeminiAdapter {
718718
}
719719

720720
async fn list_models(&self, ctx: &ProviderRequestContext) -> Result<Vec<Model>> {
721-
let url = format!("{}/models?key={}", Self::base_url(ctx), ctx.api_key);
721+
let url = format!("{}?key={}", crate::resolve_models_url(&Self::base_url(ctx)), ctx.api_key);
722722

723723
let resp = crate::apply_request_headers(self.get_client(ctx)?.get(&url), ctx)
724724
.send()

src-tauri/crates/providers/src/lib.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,11 @@ pub fn resolve_chat_url(
168168
}
169169
}
170170

171+
/// Build the model-list URL from a resolved base URL.
172+
pub fn resolve_models_url(resolved_base: &str) -> String {
173+
format!("{}/models", resolved_base.trim_end_matches('/'))
174+
}
175+
171176
/// Extract the trailing version prefix from a URL (e.g. "/v1", "/v1beta").
172177
fn extract_version_prefix(url: &str) -> Option<String> {
173178
let last_seg = url.rsplit('/').next()?;
@@ -183,6 +188,29 @@ fn extract_version_prefix(url: &str) -> Option<String> {
183188
}
184189
}
185190

191+
#[cfg(test)]
192+
mod tests {
193+
use super::*;
194+
195+
#[test]
196+
fn resolve_models_url_uses_resolved_base_url() {
197+
let base = resolve_base_url_for_type("https://api.openai.com", &ProviderType::OpenAI);
198+
assert_eq!(resolve_models_url(&base), "https://api.openai.com/v1/models");
199+
200+
let base = resolve_base_url_for_type("https://api.openai.com/v1", &ProviderType::OpenAI);
201+
assert_eq!(resolve_models_url(&base), "https://api.openai.com/v1/models");
202+
203+
let base = resolve_base_url_for_type("https://api.example.com!", &ProviderType::OpenAI);
204+
assert_eq!(resolve_models_url(&base), "https://api.example.com/models");
205+
206+
let base = resolve_base_url_for_type("https://open.bigmodel.cn/api/paas", &ProviderType::GLM);
207+
assert_eq!(
208+
resolve_models_url(&base),
209+
"https://open.bigmodel.cn/api/paas/v4/models"
210+
);
211+
}
212+
}
213+
186214
pub(crate) fn parse_base64_data_url(url: &str) -> Option<(String, String)> {
187215
let rest = url.strip_prefix("data:")?;
188216
let (mime_type, data) = rest.split_once(";base64,")?;

src-tauri/crates/providers/src/openai_compat.rs

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ use serde_json::{Map, Value};
88
use std::pin::Pin;
99

1010
use crate::reasoning::{resolve_reasoning, ReasoningStyle, ResolvedReasoning};
11-
use crate::{build_http_client, resolve_chat_url, ProviderAdapter, ProviderRequestContext};
11+
use crate::{
12+
build_http_client, resolve_chat_url, resolve_models_url, ProviderAdapter,
13+
ProviderRequestContext,
14+
};
1215

1316
pub(crate) trait OpenAICompatPolicy: Clone + Send + Sync + 'static {
1417
fn default_base_url(&self) -> &'static str {
@@ -813,6 +816,7 @@ mod tests {
813816
use crate::siliconflow::SiliconFlowPolicy;
814817
use crate::xai::XAIPolicy;
815818
use serde_json::json;
819+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
816820

817821
fn base_chat_request(model: &str) -> ChatRequest {
818822
ChatRequest {
@@ -853,6 +857,41 @@ mod tests {
853857
}
854858
}
855859

860+
async fn spawn_models_response() -> (std::net::SocketAddr, tokio::task::JoinHandle<String>) {
861+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
862+
.await
863+
.expect("bind test server");
864+
let addr = listener.local_addr().expect("server addr");
865+
let server = tokio::spawn(async move {
866+
let (mut socket, _) = listener.accept().await.expect("accept request");
867+
let mut request = Vec::new();
868+
let mut buffer = [0u8; 1024];
869+
loop {
870+
let read = socket.read(&mut buffer).await.expect("read request");
871+
if read == 0 {
872+
break;
873+
}
874+
request.extend_from_slice(&buffer[..read]);
875+
if request.windows(4).any(|window| window == b"\r\n\r\n") {
876+
break;
877+
}
878+
}
879+
let body = r#"{"data":[{"id":"gpt-test"}]}"#;
880+
let response = format!(
881+
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
882+
body.len(),
883+
body
884+
);
885+
socket
886+
.write_all(response.as_bytes())
887+
.await
888+
.expect("write response");
889+
890+
String::from_utf8_lossy(&request).into_owned()
891+
});
892+
(addr, server)
893+
}
894+
856895
#[test]
857896
fn convert_messages_omits_null_fields_for_openai_compatible_requests() {
858897
let messages = convert_messages(
@@ -891,6 +930,29 @@ mod tests {
891930
);
892931
}
893932

933+
#[tokio::test]
934+
async fn list_models_uses_resolved_base_models_url() {
935+
let (addr, server) = spawn_models_response().await;
936+
let base_url =
937+
crate::resolve_base_url_for_type(&format!("http://{}", addr), &ProviderType::OpenAI);
938+
let ctx = ProviderRequestContext {
939+
api_key: "sk-test".to_string(),
940+
key_id: "key-1".to_string(),
941+
provider_id: "provider-1".to_string(),
942+
base_url: Some(base_url),
943+
api_path: Some("/v1/chat/completions".to_string()),
944+
proxy_config: None,
945+
custom_headers: None,
946+
};
947+
let adapter = OpenAICompatAdapter::new(OpenAIPolicy);
948+
949+
let models = adapter.list_models(&ctx).await.expect("list models");
950+
let request = server.await.expect("server request");
951+
952+
assert_eq!(models[0].model_id, "gpt-test");
953+
assert!(request.starts_with("GET /v1/models HTTP/1.1"), "{request}");
954+
}
955+
894956
#[test]
895957
fn deepseek_thinking_keeps_max_tokens_when_completion_tokens_not_enabled() {
896958
let mut request = base_chat_request("deepseek-v4");
@@ -1640,7 +1702,7 @@ where
16401702
}
16411703

16421704
async fn list_models(&self, ctx: &ProviderRequestContext) -> Result<Vec<Model>> {
1643-
let url = format!("{}/models", self.base_url(ctx));
1705+
let url = resolve_models_url(&self.base_url(ctx));
16441706

16451707
let resp = crate::apply_request_headers(
16461708
self.get_client(ctx)?
@@ -1738,7 +1800,7 @@ where
17381800
return Ok(true);
17391801
}
17401802
// Fallback: probe /models endpoint, valid key → 200/400, invalid → 401/403
1741-
let url = format!("{}/models", self.base_url(ctx));
1803+
let url = resolve_models_url(&self.base_url(ctx));
17421804
let resp = crate::apply_request_headers(
17431805
self.get_client(ctx)?
17441806
.get(&url)

src-tauri/crates/providers/src/openai_responses.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ use serde::{Deserialize, Serialize};
77
use std::pin::Pin;
88

99
use crate::reasoning::{resolve_reasoning, ReasoningStyle};
10-
use crate::{build_http_client, resolve_chat_url, ProviderAdapter, ProviderRequestContext};
10+
use crate::{
11+
build_http_client, resolve_chat_url, resolve_models_url, ProviderAdapter,
12+
ProviderRequestContext,
13+
};
1114

1215
const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
1316

@@ -951,7 +954,7 @@ impl ProviderAdapter for OpenAIResponsesAdapter {
951954
}
952955

953956
async fn list_models(&self, ctx: &ProviderRequestContext) -> Result<Vec<Model>> {
954-
let url = format!("{}/models", Self::base_url(ctx));
957+
let url = resolve_models_url(&Self::base_url(ctx));
955958

956959
let resp = crate::apply_request_headers(
957960
self.get_client(ctx)?

src/components/settings/ProviderDetail.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,21 @@ const DEFAULT_HOSTS: Record<ProviderType, string> = {
116116
custom: '',
117117
};
118118

119+
const DEFAULT_VERSIONS: Record<ProviderType, string> = {
120+
openai: '/v1',
121+
openai_responses: '/v1',
122+
deepseek: '/v1',
123+
xai: '/v1',
124+
glm: '/v4',
125+
siliconflow: '/v1',
126+
anthropic: '/v1',
127+
gemini: '/v1beta',
128+
jina: '/v1',
129+
cohere: '/v2',
130+
voyage: '/v1',
131+
custom: '/v1',
132+
};
133+
119134
const REASONING_PROFILE_OPTIONS = [
120135
{ value: 'reasoning_effort', label: '自动匹配(推荐)' },
121136
{ value: 'openai_reasoning_effort', label: 'OpenAI Chat' },
@@ -401,12 +416,7 @@ export function ProviderDetail({ providerId }: ProviderDetailProps) {
401416
const host = apiHostLocal || DEFAULT_HOSTS[providerType] || '';
402417
const path = apiPathLocal || DEFAULT_PATHS[providerType] || '';
403418

404-
// Default version path per provider type
405-
const defaultVersion = providerType === 'gemini'
406-
? '/v1beta'
407-
: providerType === 'cohere'
408-
? '/v2'
409-
: '/v1';
419+
const defaultVersion = DEFAULT_VERSIONS[providerType];
410420

411421
// Check if URL ends with a versioned path like /v1, /v1beta, /v2, etc.
412422
const hasVersionSuffix = (url: string) => {
@@ -441,7 +451,9 @@ export function ProviderDetail({ providerId }: ProviderDetailProps) {
441451
}
442452
}
443453

444-
return { resolvedBase, chatUrl };
454+
const modelsUrl = `${resolvedBase.replace(/\/+$/, '')}/models`;
455+
456+
return { resolvedBase, chatUrl, modelsUrl };
445457
}, [apiHostLocal, apiPathLocal, provider?.provider_type]);
446458

447459
const filteredModels = useMemo(
@@ -1182,6 +1194,9 @@ export function ProviderDetail({ providerId }: ProviderDetailProps) {
11821194
<div style={{ marginTop: 4, fontSize: 12, color: token.colorTextQuaternary }}>
11831195
{t('settings.urlPreviewLabel')}{resolvedUrls.resolvedBase}
11841196
</div>
1197+
<div style={{ marginTop: 2, fontSize: 12, color: token.colorTextQuaternary }}>
1198+
{t('settings.modelsUrlPreviewLabel')}{resolvedUrls.modelsUrl}
1199+
</div>
11851200
</Form.Item>
11861201
<Form.Item
11871202
label={

src/components/settings/__tests__/ProviderDetail.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const mocks = vi.hoisted(() => ({
2323
testModel: vi.fn(),
2424
}));
2525

26+
vi.setConfig({ testTimeout: 15000 });
27+
2628
function createProviderFixture(): ProviderConfig {
2729
return {
2830
id: 'provider-1',
@@ -173,6 +175,53 @@ describe('ProviderDetail', () => {
173175
});
174176
});
175177

178+
it('shows model sync request preview from the resolved base URL', () => {
179+
provider.api_host = 'https://api.openai.com';
180+
provider.api_path = '/v1/chat/completions';
181+
182+
render(
183+
<App>
184+
<ProviderDetail providerId="provider-1" />
185+
</App>,
186+
);
187+
188+
expect(screen.getByText('settings.urlPreviewLabelhttps://api.openai.com/v1')).toBeInTheDocument();
189+
expect(screen.getByText('settings.modelsUrlPreviewLabelhttps://api.openai.com/v1/models')).toBeInTheDocument();
190+
expect(screen.getByText('settings.urlPreviewLabelhttps://api.openai.com/v1/chat/completions')).toBeInTheDocument();
191+
});
192+
193+
it('honors forced base URLs and provider default versions in request previews', () => {
194+
provider.api_host = 'https://api.example.com!';
195+
provider.api_path = '/v1/chat/completions';
196+
197+
const { unmount } = render(
198+
<App>
199+
<ProviderDetail providerId="provider-1" />
200+
</App>,
201+
);
202+
203+
expect(screen.getByText('settings.urlPreviewLabelhttps://api.example.com')).toBeInTheDocument();
204+
expect(screen.getByText('settings.modelsUrlPreviewLabelhttps://api.example.com/models')).toBeInTheDocument();
205+
206+
unmount();
207+
provider = {
208+
...createProviderFixture(),
209+
provider_type: 'glm',
210+
api_host: 'https://open.bigmodel.cn/api/paas',
211+
api_path: '/v4/chat/completions',
212+
};
213+
214+
render(
215+
<App>
216+
<ProviderDetail providerId="provider-1" />
217+
</App>,
218+
);
219+
220+
expect(screen.getByText('settings.urlPreviewLabelhttps://open.bigmodel.cn/api/paas/v4')).toBeInTheDocument();
221+
expect(screen.getByText('settings.modelsUrlPreviewLabelhttps://open.bigmodel.cn/api/paas/v4/models')).toBeInTheDocument();
222+
expect(screen.getByText('settings.urlPreviewLabelhttps://open.bigmodel.cn/api/paas/v4/chat/completions')).toBeInTheDocument();
223+
});
224+
176225
it('adds a model from the card-level action and derives the default group from the model id', async () => {
177226
render(
178227
<App>

src/i18n/locales/ar.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,8 +559,9 @@
559559
"endpointFormat": "تنسيق نقطة النهاية",
560560
"apiHost": "مضيف API",
561561
"apiPath": "مسار API",
562-
"urlHintExclamation": "أضف ! لإجبار استخدام URL الخام بدون إضافة /v1 تلقائياً",
562+
"urlHintExclamation": "أضف ! لإجبار استخدام URL الخام بدون إضافة بادئة الإصدار الافتراضية تلقائياً",
563563
"urlPreviewLabel": "الطلب: ",
564+
"modelsUrlPreviewLabel": "مزامنة النماذج: ",
564565
"apiKeys": "مفاتيح API",
565566
"models": "النماذج",
566567
"modelName": "اسم النموذج",

src/i18n/locales/de.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -559,8 +559,9 @@
559559
"endpointFormat": "Endpunkt-Format",
560560
"apiHost": "API-Host",
561561
"apiPath": "API-Pfad",
562-
"urlHintExclamation": "! anhängen, um die unbearbeitete URL ohne automatisches Hinzufügen von /v1 zu erzwingen",
562+
"urlHintExclamation": "! anhängen, um die unbearbeitete URL ohne automatisches Hinzufügen des Standard-Versionspräfixes zu erzwingen",
563563
"urlPreviewLabel": "Anfrage: ",
564+
"modelsUrlPreviewLabel": "Modellabgleich: ",
564565
"apiKeys": "API-Schlüssel",
565566
"models": "Modelle",
566567
"modelName": "Modellname",

src/i18n/locales/en-US.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -593,8 +593,9 @@
593593
"endpointFormat": "Endpoint Format",
594594
"apiHost": "API Host",
595595
"apiPath": "API Path",
596-
"urlHintExclamation": "Append ! to force the raw URL without auto-adding /v1",
596+
"urlHintExclamation": "Append ! to force the raw URL without auto-adding the default version prefix",
597597
"urlPreviewLabel": "Request: ",
598+
"modelsUrlPreviewLabel": "Model sync: ",
598599
"apiKeys": "API Keys",
599600
"models": "Models",
600601
"modelName": "Model Name",

0 commit comments

Comments
 (0)