Skip to content

Commit 4ed8455

Browse files
committed
feat(provider): 使用 provider_type 解析基础 URL
1 parent 53ffb51 commit 4ed8455

8 files changed

Lines changed: 120 additions & 38 deletions

File tree

src-tauri/crates/gateway/src/handlers.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use tokio_stream::wrappers::ReceiverStream;
1515

1616
use aqbot_core::crypto::decrypt_key;
1717
use aqbot_core::types::*;
18-
use aqbot_providers::{resolve_base_url, ProviderAdapter, ProviderRequestContext};
18+
use aqbot_providers::{resolve_base_url_for_type, ProviderAdapter, ProviderRequestContext};
1919

2020
use crate::auth::AuthenticatedKey;
2121
use crate::server::GatewayAppState;
@@ -148,7 +148,7 @@ pub async fn chat_completions(
148148
api_key,
149149
key_id: provider_key.id.clone(),
150150
provider_id: provider.id.clone(),
151-
base_url: Some(resolve_base_url(&provider.api_host)),
151+
base_url: Some(resolve_base_url_for_type(&provider.api_host, &provider.provider_type)),
152152
api_path: provider.api_path.clone(),
153153
proxy_config: resolved_proxy,
154154
custom_headers: provider

src-tauri/crates/gateway/src/native.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use aqbot_core::{
22
crypto::decrypt_key,
33
types::{GatewayKey, ProviderConfig, ProviderProxyConfig, ProviderType, TokenUsage},
44
};
5-
use aqbot_providers::{build_http_client, resolve_base_url, ProviderRequestContext};
5+
use aqbot_providers::{build_http_client, resolve_base_url_for_type, ProviderRequestContext};
66
use axum::{
77
body::{to_bytes, Body, Bytes},
88
extract::{Extension, Path, Request, State},
@@ -566,7 +566,7 @@ async fn resolve_native_context(
566566
api_key,
567567
key_id: provider_key.id.clone(),
568568
provider_id: provider.id.clone(),
569-
base_url: Some(resolve_base_url(&provider.api_host)),
569+
base_url: Some(resolve_base_url_for_type(&provider.api_host, &provider.provider_type)),
570570
api_path: provider.api_path.clone(),
571571
proxy_config: resolved_proxy,
572572
custom_headers: provider

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

Lines changed: 63 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,28 +51,61 @@ pub struct ProviderRequestContext {
5151
pub custom_headers: Option<std::collections::HashMap<String, String>>,
5252
}
5353

54-
/// Resolve `api_host` into a usable base URL.
54+
/// Default version path for a given provider type.
55+
pub fn default_version_for_type(provider_type: &ProviderType) -> &'static str {
56+
match provider_type {
57+
ProviderType::Gemini => "/v1beta",
58+
_ => "/v1",
59+
}
60+
}
61+
62+
/// Resolve `api_host` into a usable base URL, using the provider type to
63+
/// determine the default version path (e.g. `/v1` for OpenAI, `/v1beta` for Gemini).
5564
///
56-
/// - Trailing `!` → force mode: strip `!`, return as-is (no auto `/v1`).
57-
/// - Already ends with `/v1` → return as-is.
58-
/// - Otherwise → append `/v1`.
65+
/// - Trailing `!` → force mode: strip `!`, return as-is.
66+
/// - Already ends with a versioned path (e.g. `/v1`, `/v1beta`) → return as-is.
67+
/// - Otherwise → append the default version path for this provider type.
68+
pub fn resolve_base_url_for_type(api_host: &str, provider_type: &ProviderType) -> String {
69+
let default_version = default_version_for_type(provider_type);
70+
resolve_base_url_inner(api_host, default_version)
71+
}
72+
73+
/// Resolve `api_host` into a usable base URL (defaults to `/v1`).
5974
pub fn resolve_base_url(api_host: &str) -> String {
75+
resolve_base_url_inner(api_host, "/v1")
76+
}
77+
78+
fn resolve_base_url_inner(api_host: &str, default_version: &str) -> String {
6079
let trimmed = api_host.trim_end_matches('/');
6180
if let Some(forced) = trimmed.strip_suffix('!') {
6281
forced.trim_end_matches('/').to_string()
63-
} else if trimmed.ends_with("/v1") {
82+
} else if has_version_suffix(trimmed) {
6483
trimmed.to_string()
6584
} else {
66-
format!("{}/v1", trimmed)
85+
format!("{}{}", trimmed, default_version)
86+
}
87+
}
88+
89+
/// Check whether the URL already ends with a versioned path segment
90+
/// like `/v1`, `/v1beta`, `/v2`, `/v1beta1`, etc.
91+
fn has_version_suffix(url: &str) -> bool {
92+
let last_seg = url.rsplit('/').next().unwrap_or("");
93+
// Match patterns like v1, v2, v1beta, v1beta1, v1alpha, etc.
94+
let bytes = last_seg.as_bytes();
95+
if bytes.len() < 2 || bytes[0] != b'v' {
96+
return false;
6797
}
98+
// After 'v', must start with digit(s), optionally followed by alpha tag
99+
let rest = &last_seg[1..];
100+
rest.starts_with(|c: char| c.is_ascii_digit())
68101
}
69102

70103
/// Build the full chat/completion URL from resolved `base_url` and optional `api_path`.
71104
///
72105
/// When `api_path` is provided:
73106
/// - Trailing `!` on api_path → force: concat resolved base + raw path (strip `!`).
74-
/// - No `!` → auto-dedup: if both resolved base ends with `/v1` and
75-
/// api_path starts with `/v1`, strip the duplicate prefix from api_path.
107+
/// - No `!` → auto-dedup: if both resolved base and api_path share a common
108+
/// versioned prefix (e.g. `/v1`, `/v1beta`), strip the duplicate from api_path.
76109
///
77110
/// When `api_path` is absent, returns `resolved_base_url + default_suffix`
78111
/// (e.g. `/chat/completions`).
@@ -98,18 +131,35 @@ pub fn resolve_chat_url(
98131
} else {
99132
format!("/{}", path)
100133
};
101-
// Auto dedup: if both have /v1, strip from api_path
102-
if base.ends_with("/v1") && p.starts_with("/v1") {
103-
format!("{}{}", base, &p[3..])
104-
} else {
105-
format!("{}{}", base, p)
134+
// Auto dedup: if base ends with a version prefix that matches
135+
// the start of api_path, strip it from api_path
136+
if let Some(ver) = extract_version_prefix(base) {
137+
if p.starts_with(&ver) {
138+
return format!("{}{}", base, &p[ver.len()..]);
139+
}
106140
}
141+
format!("{}{}", base, p)
107142
}
108143
}
109144
_ => format!("{}{}", base, default_suffix),
110145
}
111146
}
112147

148+
/// Extract the trailing version prefix from a URL (e.g. "/v1", "/v1beta").
149+
fn extract_version_prefix(url: &str) -> Option<String> {
150+
let last_seg = url.rsplit('/').next()?;
151+
let bytes = last_seg.as_bytes();
152+
if bytes.len() < 2 || bytes[0] != b'v' {
153+
return None;
154+
}
155+
let rest = &last_seg[1..];
156+
if rest.starts_with(|c: char| c.is_ascii_digit()) {
157+
Some(format!("/{}", last_seg))
158+
} else {
159+
None
160+
}
161+
}
162+
113163
pub(crate) fn parse_base64_data_url(url: &str) -> Option<(String, String)> {
114164
let rest = url.strip_prefix("data:")?;
115165
let (mime_type, data) = rest.split_once(";base64,")?;

src-tauri/src/commands/conversations.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use crate::AppState;
22
use aqbot_core::types::*;
3-
use aqbot_providers::{registry::ProviderRegistry, resolve_base_url, ProviderRequestContext};
3+
use aqbot_providers::{registry::ProviderRegistry, resolve_base_url_for_type, ProviderRequestContext};
44
use base64::Engine;
55
use sea_orm::*;
66
use std::sync::atomic::AtomicBool;
@@ -668,7 +668,7 @@ async fn generate_ai_title(
668668
api_key: dk,
669669
key_id: key_row.id.clone(),
670670
provider_id: provider.id.clone(),
671-
base_url: Some(resolve_base_url(&provider.api_host)),
671+
base_url: Some(resolve_base_url_for_type(&provider.api_host, &provider.provider_type)),
672672
api_path: provider.api_path.clone(),
673673
proxy_config: proxy,
674674
custom_headers: provider
@@ -844,7 +844,7 @@ pub async fn regenerate_conversation_title(
844844
api_key: decrypted_key,
845845
key_id: key_row.id.clone(),
846846
provider_id: provider.id.clone(),
847-
base_url: Some(resolve_base_url(&provider.api_host)),
847+
base_url: Some(resolve_base_url_for_type(&provider.api_host, &provider.provider_type)),
848848
api_path: provider.api_path.clone(),
849849
proxy_config: resolved_proxy,
850850
custom_headers: provider
@@ -1733,7 +1733,7 @@ pub async fn send_message(
17331733
api_key: decrypted_key,
17341734
key_id: key_row.id.clone(),
17351735
provider_id: provider.id.clone(),
1736-
base_url: Some(resolve_base_url(&provider.api_host)),
1736+
base_url: Some(resolve_base_url_for_type(&provider.api_host, &provider.provider_type)),
17371737
api_path: provider.api_path.clone(),
17381738
proxy_config: resolved_proxy,
17391739
custom_headers: provider
@@ -2018,7 +2018,7 @@ pub async fn regenerate_message(
20182018
api_key: decrypted_key,
20192019
key_id: key_row.id.clone(),
20202020
provider_id: provider.id.clone(),
2021-
base_url: Some(resolve_base_url(&provider.api_host)),
2021+
base_url: Some(resolve_base_url_for_type(&provider.api_host, &provider.provider_type)),
20222022
api_path: provider.api_path.clone(),
20232023
proxy_config: resolved_proxy,
20242024
custom_headers: provider
@@ -2286,7 +2286,7 @@ pub async fn regenerate_with_model(
22862286
api_key: decrypted_key,
22872287
key_id: key_row.id.clone(),
22882288
provider_id: provider.id.clone(),
2289-
base_url: Some(resolve_base_url(&provider.api_host)),
2289+
base_url: Some(resolve_base_url_for_type(&provider.api_host, &provider.provider_type)),
22902290
api_path: provider.api_path.clone(),
22912291
proxy_config: resolved_proxy,
22922292
custom_headers: provider
@@ -2544,7 +2544,7 @@ async fn do_compress(
25442544
api_key: comp_key,
25452545
key_id: comp_key_id,
25462546
provider_id: comp_provider.id.clone(),
2547-
base_url: Some(resolve_base_url(&comp_provider.api_host)),
2547+
base_url: Some(resolve_base_url_for_type(&comp_provider.api_host, &comp_provider.provider_type)),
25482548
api_path: comp_provider.api_path.clone(),
25492549
proxy_config: comp_proxy,
25502550
custom_headers: comp_provider

src-tauri/src/commands/providers.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ pub async fn validate_provider_key(
119119
api_key: decrypted,
120120
key_id: key_id.clone(),
121121
provider_id: provider.id.clone(),
122-
base_url: Some(aqbot_providers::resolve_base_url(&provider.api_host)),
122+
base_url: Some(aqbot_providers::resolve_base_url_for_type(&provider.api_host, &provider.provider_type)),
123123
api_path: provider.api_path.clone(),
124124
proxy_config: resolved_proxy,
125125
custom_headers: provider
@@ -217,7 +217,7 @@ pub async fn fetch_remote_models(
217217
api_key: decrypted,
218218
key_id: key_row.id.clone(),
219219
provider_id: provider.id.clone(),
220-
base_url: Some(aqbot_providers::resolve_base_url(&provider.api_host)),
220+
base_url: Some(aqbot_providers::resolve_base_url_for_type(&provider.api_host, &provider.provider_type)),
221221
api_path: provider.api_path.clone(),
222222
proxy_config: resolved_proxy,
223223
custom_headers: provider
@@ -264,7 +264,7 @@ pub async fn test_model(
264264
api_key: decrypted,
265265
key_id: key_row.id.clone(),
266266
provider_id: provider.id.clone(),
267-
base_url: Some(aqbot_providers::resolve_base_url(&provider.api_host)),
267+
base_url: Some(aqbot_providers::resolve_base_url_for_type(&provider.api_host, &provider.provider_type)),
268268
api_path: provider.api_path.clone(),
269269
proxy_config: resolved_proxy,
270270
custom_headers: provider

src-tauri/src/indexing.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use aqbot_core::types::*;
1616
use aqbot_core::vector_store::{VectorSearchResult, VectorStore};
1717

1818
use aqbot_providers::{
19-
registry::ProviderRegistry, resolve_base_url, ProviderAdapter, ProviderRequestContext,
19+
registry::ProviderRegistry, resolve_base_url_for_type, ProviderAdapter, ProviderRequestContext,
2020
};
2121

2222
// ── AsyncEmbedFn implementation ──────────────────────────────────────────────
@@ -83,7 +83,7 @@ pub async fn build_embed_context(
8383
api_key: decrypted_key,
8484
key_id: key_row.id.clone(),
8585
provider_id: provider.id.clone(),
86-
base_url: Some(resolve_base_url(&provider.api_host)),
86+
base_url: Some(resolve_base_url_for_type(&provider.api_host, &provider.provider_type)),
8787
api_path: None,
8888
proxy_config: resolved_proxy,
8989
custom_headers: provider

src/components/settings/ProviderDetail.tsx

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -260,26 +260,44 @@ export function ProviderDetail({ providerId }: ProviderDetailProps) {
260260

261261
// Resolve actual request URLs for preview
262262
const resolvedUrls = useMemo(() => {
263-
const host = apiHostLocal || DEFAULT_HOSTS[provider?.provider_type ?? 'custom'] || '';
264-
const path = apiPathLocal || DEFAULT_PATHS[provider?.provider_type ?? 'custom'] || '';
263+
const providerType = provider?.provider_type ?? 'custom';
264+
const host = apiHostLocal || DEFAULT_HOSTS[providerType] || '';
265+
const path = apiPathLocal || DEFAULT_PATHS[providerType] || '';
265266

266-
// resolve base_url: strip trailing !, auto-add /v1 if missing
267+
// Default version path per provider type
268+
const defaultVersion = providerType === 'gemini' ? '/v1beta' : '/v1';
269+
270+
// Check if URL ends with a versioned path like /v1, /v1beta, /v2, etc.
271+
const hasVersionSuffix = (url: string) => {
272+
const lastSeg = url.split('/').pop() || '';
273+
return /^v\d/.test(lastSeg);
274+
};
275+
// Extract version prefix like "/v1", "/v1beta"
276+
const extractVersionPrefix = (url: string): string | null => {
277+
const lastSeg = url.split('/').pop() || '';
278+
return /^v\d/.test(lastSeg) ? `/${lastSeg}` : null;
279+
};
280+
281+
// resolve base_url: strip trailing !, auto-add default version if missing
267282
const trimmed = host.replace(/\/+$/, '');
268283
const forced = trimmed.endsWith('!');
269284
const rawHost = forced ? trimmed.slice(0, -1).replace(/\/+$/, '') : trimmed;
270-
const resolvedBase = forced ? rawHost : rawHost.endsWith('/v1') ? rawHost : `${rawHost}/v1`;
285+
const resolvedBase = forced ? rawHost : hasVersionSuffix(rawHost) ? rawHost : `${rawHost}${defaultVersion}`;
271286

272-
// resolve chat url: strip ! from path, dedup /v1
287+
// resolve chat url: strip ! from path, dedup version prefix
273288
const pathForced = path.endsWith('!');
274289
const rawPath = pathForced ? path.slice(0, -1) : path;
275290
const normalizedPath = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
276291
let chatUrl: string;
277292
if (pathForced) {
278293
chatUrl = `${resolvedBase}${normalizedPath}`;
279-
} else if (resolvedBase.endsWith('/v1') && normalizedPath.startsWith('/v1')) {
280-
chatUrl = `${resolvedBase}${normalizedPath.slice(3)}`;
281294
} else {
282-
chatUrl = `${resolvedBase}${normalizedPath}`;
295+
const ver = extractVersionPrefix(resolvedBase);
296+
if (ver && normalizedPath.startsWith(ver)) {
297+
chatUrl = `${resolvedBase}${normalizedPath.slice(ver.length)}`;
298+
} else {
299+
chatUrl = `${resolvedBase}${normalizedPath}`;
300+
}
283301
}
284302

285303
return { resolvedBase, chatUrl };
@@ -639,7 +657,20 @@ export function ProviderDetail({ providerId }: ProviderDetailProps) {
639657
<Title level={4} className="!mb-0">
640658
{provider.name}
641659
</Title>
642-
<Text type="secondary" className="text-sm">({t('settings.endpointFormat')}{provider.provider_type === 'openai' ? 'OpenAI' : provider.provider_type === 'openai_responses' ? 'OpenAI Responses' : provider.provider_type === 'anthropic' ? 'Anthropic' : provider.provider_type === 'gemini' ? 'Gemini' : provider.provider_type})</Text>
660+
<Select
661+
size="small"
662+
value={provider.provider_type}
663+
onChange={(val) => updateProvider(providerId, { provider_type: val as ProviderType })}
664+
style={{ minWidth: 140 }}
665+
options={[
666+
{ label: 'OpenAI', value: 'openai' },
667+
{ label: 'OpenAI Responses', value: 'openai_responses' },
668+
{ label: 'Anthropic', value: 'anthropic' },
669+
{ label: 'Gemini', value: 'gemini' },
670+
{ label: t('settings.custom', '自定义'), value: 'custom' },
671+
]}
672+
popupMatchSelectWidth={false}
673+
/>
643674
</div>
644675
</div>
645676
</div>

src/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export interface CreateProviderInput {
4646

4747
export interface UpdateProviderInput {
4848
name?: string;
49+
provider_type?: ProviderType;
4950
api_host?: string;
5051
api_path?: string | null;
5152
enabled?: boolean;

0 commit comments

Comments
 (0)