mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 10:57:03 +08:00
fix(provider): refine endpoint default paths for openai and claude roots
This commit is contained in:
@@ -3,6 +3,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||||||
use aether_data_contracts::repository::provider_catalog::{
|
use aether_data_contracts::repository::provider_catalog::{
|
||||||
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
|
||||||
};
|
};
|
||||||
|
use aether_provider_transport::provider_types::is_codex_cli_backend_url;
|
||||||
use aether_provider_transport::url::{
|
use aether_provider_transport::url::{
|
||||||
build_bigmodel_coding_models_url, build_openai_compatible_models_url,
|
build_bigmodel_coding_models_url, build_openai_compatible_models_url,
|
||||||
openai_compatible_base_includes_unversioned_api_root,
|
openai_compatible_base_includes_unversioned_api_root,
|
||||||
@@ -611,15 +612,18 @@ fn build_codex_models_url(base_url: &str) -> Option<String> {
|
|||||||
if let Some(url) = build_bigmodel_coding_models_url(base_url) {
|
if let Some(url) = build_bigmodel_coding_models_url(base_url) {
|
||||||
return Some(url);
|
return Some(url);
|
||||||
}
|
}
|
||||||
if openai_compatible_base_includes_unversioned_api_root(base_url) {
|
|
||||||
return build_openai_compatible_models_url(base_url);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (trimmed_base_url, query) = split_url_query(base_url);
|
let (trimmed_base_url, query) = split_url_query(base_url);
|
||||||
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
|
let trimmed_base_url = trimmed_base_url.trim_end_matches('/');
|
||||||
if trimmed_base_url.is_empty() {
|
if trimmed_base_url.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let is_codex_backend = is_codex_cli_backend_url(trimmed_base_url)
|
||||||
|
|| trimmed_base_url.ends_with("/codex")
|
||||||
|
|| trimmed_base_url.ends_with("/models");
|
||||||
|
if !is_codex_backend && openai_compatible_base_includes_unversioned_api_root(base_url) {
|
||||||
|
return build_openai_compatible_models_url(base_url);
|
||||||
|
}
|
||||||
let mut url = if trimmed_base_url.ends_with("/models") {
|
let mut url = if trimmed_base_url.ends_with("/models") {
|
||||||
trimmed_base_url.to_string()
|
trimmed_base_url.to_string()
|
||||||
} else {
|
} else {
|
||||||
@@ -971,7 +975,7 @@ mod tests {
|
|||||||
"https://open.bigmodel.cn/api/coding/paas/v4"
|
"https://open.bigmodel.cn/api/coding/paas/v4"
|
||||||
),
|
),
|
||||||
Some((
|
Some((
|
||||||
"https://open.bigmodel.cn/api/coding/paas/v4/models/models".to_string(),
|
"https://open.bigmodel.cn/api/coding/paas/v4/models".to_string(),
|
||||||
"openai:chat".to_string()
|
"openai:chat".to_string()
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
@@ -982,7 +986,7 @@ mod tests {
|
|||||||
"https://open.bigmodel.cn/api/coding/paas/v4"
|
"https://open.bigmodel.cn/api/coding/paas/v4"
|
||||||
),
|
),
|
||||||
Some((
|
Some((
|
||||||
"https://open.bigmodel.cn/api/coding/paas/v4/models/models".to_string(),
|
"https://open.bigmodel.cn/api/coding/paas/v4/models".to_string(),
|
||||||
"openai:responses".to_string()
|
"openai:responses".to_string()
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
@@ -997,6 +1001,20 @@ mod tests {
|
|||||||
"openai:chat".to_string()
|
"openai:chat".to_string()
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_models_fetch_url("openai", "openai:chat", "https://proxy.example.com/openai"),
|
||||||
|
Some((
|
||||||
|
"https://proxy.example.com/openai/models".to_string(),
|
||||||
|
"openai:chat".to_string()
|
||||||
|
))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_models_fetch_url("openai", "openai:chat", "https://proxy.example.com"),
|
||||||
|
Some((
|
||||||
|
"https://proxy.example.com/v1/models".to_string(),
|
||||||
|
"openai:chat".to_string()
|
||||||
|
))
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
build_models_fetch_url("codex", "openai:responses", "https://proxy.example.com/api"),
|
build_models_fetch_url("codex", "openai:responses", "https://proxy.example.com/api"),
|
||||||
Some((
|
Some((
|
||||||
@@ -1004,6 +1022,17 @@ mod tests {
|
|||||||
"openai:responses".to_string()
|
"openai:responses".to_string()
|
||||||
))
|
))
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_models_fetch_url(
|
||||||
|
"anthropic",
|
||||||
|
"claude:messages",
|
||||||
|
"https://proxy.example.com/api"
|
||||||
|
),
|
||||||
|
Some((
|
||||||
|
"https://proxy.example.com/api/models".to_string(),
|
||||||
|
"claude:messages".to_string()
|
||||||
|
))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -822,7 +822,7 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.url,
|
plan.url,
|
||||||
"https://open.bigmodel.cn/api/coding/paas/v4/models/models"
|
"https://open.bigmodel.cn/api/coding/paas/v4/models"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan.headers.get("authorization").map(String::as_str),
|
plan.headers.get("authorization").map(String::as_str),
|
||||||
|
|||||||
@@ -1199,7 +1199,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.as_deref(),
|
.as_deref(),
|
||||||
Some("https://api.openai.example/root/v1/embeddings?tenant=request&trace=1")
|
Some("https://api.openai.example/root/embeddings?tenant=request&trace=1")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
build_transport_request_url(
|
build_transport_request_url(
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ fn openai_image_base_includes_operation_path(base_url: &str) -> bool {
|
|||||||
pub fn build_claude_messages_url(upstream_base_url: &str, query: Option<&str>) -> String {
|
pub fn build_claude_messages_url(upstream_base_url: &str, query: Option<&str>) -> String {
|
||||||
let (trimmed, base_query) = split_base_url_query(upstream_base_url);
|
let (trimmed, base_query) = split_base_url_query(upstream_base_url);
|
||||||
let trimmed = trimmed.trim_end_matches('/');
|
let trimmed = trimmed.trim_end_matches('/');
|
||||||
let mut url = if trimmed.ends_with("/v1") {
|
let mut url = if v1_compatible_base_includes_api_root(trimmed) {
|
||||||
format!("{trimmed}/messages")
|
format!("{trimmed}/messages")
|
||||||
} else {
|
} else {
|
||||||
format!("{trimmed}/v1/messages")
|
format!("{trimmed}/v1/messages")
|
||||||
@@ -210,12 +210,10 @@ pub fn build_bigmodel_coding_models_url(upstream_base_url: &str) -> Option<Strin
|
|||||||
.ok()
|
.ok()
|
||||||
.map(|url| url.path().trim_end_matches('/').to_string())
|
.map(|url| url.path().trim_end_matches('/').to_string())
|
||||||
.unwrap_or_else(|| trimmed_base_url.trim_end_matches('/').to_string());
|
.unwrap_or_else(|| trimmed_base_url.trim_end_matches('/').to_string());
|
||||||
let mut url = if path.ends_with("/models/models") {
|
let mut url = if path.ends_with("/models") {
|
||||||
trimmed_base_url.to_string()
|
trimmed_base_url.to_string()
|
||||||
} else if path.ends_with("/models") {
|
|
||||||
format!("{trimmed_base_url}/models")
|
|
||||||
} else {
|
} else {
|
||||||
format!("{trimmed_base_url}/models/models")
|
format!("{trimmed_base_url}/models")
|
||||||
};
|
};
|
||||||
append_merged_query(&mut url, base_query, None, None, &[]);
|
append_merged_query(&mut url, base_query, None, None, &[]);
|
||||||
Some(url)
|
Some(url)
|
||||||
@@ -303,13 +301,25 @@ pub fn openai_compatible_base_includes_api_root(base_url: &str) -> bool {
|
|||||||
|| openai_compatible_base_includes_unversioned_api_root(trimmed)
|
|| openai_compatible_base_includes_unversioned_api_root(trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn v1_compatible_base_includes_api_root(base_url: &str) -> bool {
|
||||||
|
let trimmed = base_url.trim().trim_end_matches('/');
|
||||||
|
trimmed.ends_with("/v1") || openai_compatible_base_includes_unversioned_api_root(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn openai_compatible_base_includes_unversioned_api_root(base_url: &str) -> bool {
|
pub fn openai_compatible_base_includes_unversioned_api_root(base_url: &str) -> bool {
|
||||||
let trimmed = base_url.trim().trim_end_matches('/');
|
let trimmed = base_url.trim().trim_end_matches('/');
|
||||||
let path = Url::parse(trimmed)
|
let path = Url::parse(trimmed)
|
||||||
.ok()
|
.ok()
|
||||||
.map(|url| url.path().trim_end_matches('/').to_ascii_lowercase())
|
.map(|url| url.path().trim_end_matches('/').to_ascii_lowercase())
|
||||||
.unwrap_or_else(|| trimmed.to_ascii_lowercase());
|
.unwrap_or_else(|| {
|
||||||
path.ends_with("/api")
|
trimmed
|
||||||
|
.split_once('/')
|
||||||
|
.map(|(_, path)| format!("/{path}"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
});
|
||||||
|
!path.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bigmodel_coding_base_includes_api_root(base_url: &str) -> bool {
|
fn bigmodel_coding_base_includes_api_root(base_url: &str) -> bool {
|
||||||
@@ -334,7 +344,7 @@ fn bigmodel_coding_models_base_is_supported(base_url: &str) -> bool {
|
|||||||
}
|
}
|
||||||
matches!(
|
matches!(
|
||||||
parsed.path().trim_end_matches('/'),
|
parsed.path().trim_end_matches('/'),
|
||||||
"/api/coding/paas/v4" | "/api/coding/paas/v4/models" | "/api/coding/paas/v4/models/models"
|
"/api/coding/paas/v4" | "/api/coding/paas/v4/models"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +437,7 @@ fn merge_query_string(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
build_bigmodel_coding_models_url, build_gemini_content_url,
|
build_bigmodel_coding_models_url, build_claude_messages_url, build_gemini_content_url,
|
||||||
build_gemini_files_passthrough_url, build_gemini_video_predict_long_running_url,
|
build_gemini_files_passthrough_url, build_gemini_video_predict_long_running_url,
|
||||||
build_openai_chat_url, build_openai_compatible_models_url, build_openai_image_url,
|
build_openai_chat_url, build_openai_compatible_models_url, build_openai_image_url,
|
||||||
build_openai_responses_url, build_passthrough_path_url,
|
build_openai_responses_url, build_passthrough_path_url,
|
||||||
@@ -484,6 +494,14 @@ mod tests {
|
|||||||
build_openai_chat_url("https://proxy.example.com/api", Some("trace=1")),
|
build_openai_chat_url("https://proxy.example.com/api", Some("trace=1")),
|
||||||
"https://proxy.example.com/api/chat/completions?trace=1"
|
"https://proxy.example.com/api/chat/completions?trace=1"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_openai_chat_url("https://proxy.example.com/openai", None),
|
||||||
|
"https://proxy.example.com/openai/chat/completions"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_openai_chat_url("https://proxy.example.com", None),
|
||||||
|
"https://proxy.example.com/v1/chat/completions"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
build_openai_responses_url("https://proxy.example.com/api", None, false),
|
build_openai_responses_url("https://proxy.example.com/api", None, false),
|
||||||
"https://proxy.example.com/api/responses"
|
"https://proxy.example.com/api/responses"
|
||||||
@@ -499,25 +517,38 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bigmodel_coding_models_url_uses_double_models_resource() {
|
fn claude_messages_url_preserves_v1_and_unversioned_api_roots() {
|
||||||
|
assert_eq!(
|
||||||
|
build_claude_messages_url("https://api.anthropic.example/v1", Some("trace=1")),
|
||||||
|
"https://api.anthropic.example/v1/messages?trace=1"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_claude_messages_url("https://proxy.example.com/api", None),
|
||||||
|
"https://proxy.example.com/api/messages"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_claude_messages_url("https://proxy.example.com/anthropic", None),
|
||||||
|
"https://proxy.example.com/anthropic/messages"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_claude_messages_url("https://api.anthropic.example", None),
|
||||||
|
"https://api.anthropic.example/v1/messages"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bigmodel_coding_models_url_uses_models_resource() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
build_bigmodel_coding_models_url(
|
build_bigmodel_coding_models_url(
|
||||||
"https://open.bigmodel.cn/api/coding/paas/v4?tenant=demo"
|
"https://open.bigmodel.cn/api/coding/paas/v4?tenant=demo"
|
||||||
)
|
)
|
||||||
.as_deref(),
|
.as_deref(),
|
||||||
Some("https://open.bigmodel.cn/api/coding/paas/v4/models/models?tenant=demo")
|
Some("https://open.bigmodel.cn/api/coding/paas/v4/models?tenant=demo")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
build_bigmodel_coding_models_url("https://open.bigmodel.cn/api/coding/paas/v4/models")
|
build_bigmodel_coding_models_url("https://open.bigmodel.cn/api/coding/paas/v4/models")
|
||||||
.as_deref(),
|
.as_deref(),
|
||||||
Some("https://open.bigmodel.cn/api/coding/paas/v4/models/models")
|
Some("https://open.bigmodel.cn/api/coding/paas/v4/models")
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
build_bigmodel_coding_models_url(
|
|
||||||
"https://open.bigmodel.cn/api/coding/paas/v4/models/models"
|
|
||||||
)
|
|
||||||
.as_deref(),
|
|
||||||
Some("https://open.bigmodel.cn/api/coding/paas/v4/models/models")
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,6 +559,14 @@ mod tests {
|
|||||||
.as_deref(),
|
.as_deref(),
|
||||||
Some("https://proxy.example.com/api/models?tenant=demo")
|
Some("https://proxy.example.com/api/models?tenant=demo")
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_openai_compatible_models_url("https://proxy.example.com/openai").as_deref(),
|
||||||
|
Some("https://proxy.example.com/openai/models")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
build_openai_compatible_models_url("https://proxy.example.com").as_deref(),
|
||||||
|
Some("https://proxy.example.com/v1/models")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -153,10 +153,17 @@
|
|||||||
<Label class="text-xs text-muted-foreground">自定义路径</Label>
|
<Label class="text-xs text-muted-foreground">自定义路径</Label>
|
||||||
<Input
|
<Input
|
||||||
:model-value="getDisplayedPath(endpoint)"
|
:model-value="getDisplayedPath(endpoint)"
|
||||||
:placeholder="getDefaultPath(endpoint.api_format, getEndpointEditState(endpoint.id)?.url ?? endpoint.base_url) || '留空使用默认'"
|
:placeholder="getEndpointDefaultPath(endpoint) || '留空使用默认'"
|
||||||
:disabled="isFixedProvider"
|
:disabled="isFixedProvider"
|
||||||
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
|
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
|
||||||
/>
|
/>
|
||||||
|
<p
|
||||||
|
v-if="getEndpointDefaultPath(endpoint)"
|
||||||
|
class="text-[10px] text-muted-foreground truncate"
|
||||||
|
:title="getEndpointDefaultPath(endpoint)"
|
||||||
|
>
|
||||||
|
当前默认路径:{{ getEndpointDefaultPath(endpoint) }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 保存/撤销按钮(URL/路径有修改时显示) -->
|
<!-- 保存/撤销按钮(URL/路径有修改时显示) -->
|
||||||
@@ -963,6 +970,13 @@
|
|||||||
size="sm"
|
size="sm"
|
||||||
:placeholder="newEndpointDefaultPath || '留空使用默认'"
|
:placeholder="newEndpointDefaultPath || '留空使用默认'"
|
||||||
/>
|
/>
|
||||||
|
<p
|
||||||
|
v-if="newEndpointDefaultPath"
|
||||||
|
class="text-[10px] text-muted-foreground truncate"
|
||||||
|
:title="newEndpointDefaultPath"
|
||||||
|
>
|
||||||
|
当前默认路径:{{ newEndpointDefaultPath }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1859,6 +1873,10 @@ function getDefaultPath(apiFormat: string, baseUrl?: string): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getEndpointDefaultPath(endpoint: ProviderEndpoint): string {
|
||||||
|
return getDefaultPath(endpoint.api_format, getEndpointEditState(endpoint.id)?.url ?? endpoint.base_url)
|
||||||
|
}
|
||||||
|
|
||||||
function getDisplayedPath(endpoint: ProviderEndpoint): string {
|
function getDisplayedPath(endpoint: ProviderEndpoint): string {
|
||||||
if (isFixedProvider.value) {
|
if (isFixedProvider.value) {
|
||||||
return getDefaultPath(endpoint.api_format, endpoint.base_url)
|
return getDefaultPath(endpoint.api_format, endpoint.base_url)
|
||||||
|
|||||||
+55
-1
@@ -8,6 +8,7 @@ const apiFormats = [
|
|||||||
{ value: 'gemini:embedding', default_path: '/v1beta/models/{model}:{action}' },
|
{ value: 'gemini:embedding', default_path: '/v1beta/models/{model}:{action}' },
|
||||||
{ value: 'openai:responses', default_path: '/v1/responses' },
|
{ value: 'openai:responses', default_path: '/v1/responses' },
|
||||||
{ value: 'openai:embedding', default_path: '/v1/embeddings' },
|
{ value: 'openai:embedding', default_path: '/v1/embeddings' },
|
||||||
|
{ value: 'claude:messages', default_path: '/v1/messages' },
|
||||||
]
|
]
|
||||||
|
|
||||||
describe('endpoint default paths', () => {
|
describe('endpoint default paths', () => {
|
||||||
@@ -47,7 +48,7 @@ describe('endpoint default paths', () => {
|
|||||||
})).toBe('/responses')
|
})).toBe('/responses')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('drops /v1 from OpenAI-compatible defaults when base URL ends with /api', () => {
|
it('drops /v1 from OpenAI-compatible defaults when base URL includes a path', () => {
|
||||||
expect(getDefaultEndpointPath({
|
expect(getDefaultEndpointPath({
|
||||||
apiFormat: 'openai:chat',
|
apiFormat: 'openai:chat',
|
||||||
providerType: 'custom',
|
providerType: 'custom',
|
||||||
@@ -61,5 +62,58 @@ describe('endpoint default paths', () => {
|
|||||||
baseUrl: 'https://proxy.example.com/api?tenant=demo',
|
baseUrl: 'https://proxy.example.com/api?tenant=demo',
|
||||||
apiFormats,
|
apiFormats,
|
||||||
})).toBe('/embeddings')
|
})).toBe('/embeddings')
|
||||||
|
|
||||||
|
expect(getDefaultEndpointPath({
|
||||||
|
apiFormat: 'openai:chat',
|
||||||
|
providerType: 'custom',
|
||||||
|
baseUrl: 'https://proxy.example.com/openai',
|
||||||
|
apiFormats,
|
||||||
|
})).toBe('/chat/completions')
|
||||||
|
|
||||||
|
expect(getDefaultEndpointPath({
|
||||||
|
apiFormat: 'openai:chat',
|
||||||
|
providerType: 'custom',
|
||||||
|
baseUrl: 'https://proxy.example.com',
|
||||||
|
apiFormats,
|
||||||
|
})).toBe('/v1/chat/completions')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops /v1 from OpenAI-compatible defaults when base URL already includes a known API root', () => {
|
||||||
|
expect(getDefaultEndpointPath({
|
||||||
|
apiFormat: 'openai:chat',
|
||||||
|
providerType: 'custom',
|
||||||
|
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||||
|
apiFormats,
|
||||||
|
})).toBe('/chat/completions')
|
||||||
|
|
||||||
|
expect(getDefaultEndpointPath({
|
||||||
|
apiFormat: 'openai:responses',
|
||||||
|
providerType: 'custom',
|
||||||
|
baseUrl: 'https://api.openai.example/v1',
|
||||||
|
apiFormats,
|
||||||
|
})).toBe('/responses')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops /v1 from Claude Messages defaults for v1 and unversioned api roots', () => {
|
||||||
|
expect(getDefaultEndpointPath({
|
||||||
|
apiFormat: 'claude:messages',
|
||||||
|
providerType: 'custom',
|
||||||
|
baseUrl: 'https://api.anthropic.example/v1',
|
||||||
|
apiFormats,
|
||||||
|
})).toBe('/messages')
|
||||||
|
|
||||||
|
expect(getDefaultEndpointPath({
|
||||||
|
apiFormat: 'claude:messages',
|
||||||
|
providerType: 'custom',
|
||||||
|
baseUrl: 'https://proxy.example.com/api',
|
||||||
|
apiFormats,
|
||||||
|
})).toBe('/messages')
|
||||||
|
|
||||||
|
expect(getDefaultEndpointPath({
|
||||||
|
apiFormat: 'claude:messages',
|
||||||
|
providerType: 'custom',
|
||||||
|
baseUrl: 'https://proxy.example.com/anthropic',
|
||||||
|
apiFormats,
|
||||||
|
})).toBe('/messages')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,17 +15,63 @@ function isCodexUrl(baseUrl: string): boolean {
|
|||||||
return url.includes('/backend-api/codex') || url.endsWith('/codex')
|
return url.includes('/backend-api/codex') || url.endsWith('/codex')
|
||||||
}
|
}
|
||||||
|
|
||||||
function baseUrlEndsWithApiRoot(baseUrl?: string | null): boolean {
|
function parseBaseUrlParts(baseUrl?: string | null): { host: string; path: string } | null {
|
||||||
const raw = (baseUrl || '').trim()
|
const raw = (baseUrl || '').trim()
|
||||||
if (!raw) return false
|
if (!raw) return null
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(raw)
|
const parsed = new URL(raw)
|
||||||
return parsed.pathname.replace(/\/+$/, '').toLowerCase().endsWith('/api')
|
return {
|
||||||
|
host: parsed.hostname.toLowerCase(),
|
||||||
|
path: parsed.pathname.replace(/\/+$/, '').toLowerCase(),
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return raw.split('?')[0].replace(/\/+$/, '').toLowerCase().endsWith('/api')
|
const pathStart = raw.indexOf('/')
|
||||||
|
return {
|
||||||
|
host: '',
|
||||||
|
path: pathStart >= 0 ? raw.slice(pathStart).split('?')[0].replace(/\/+$/, '').toLowerCase() : '',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function baseUrlHasPathApiRoot(baseUrl?: string | null): boolean {
|
||||||
|
const path = parseBaseUrlParts(baseUrl)?.path
|
||||||
|
return !!path && path !== '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseUrlEndsWithV1Root(baseUrl?: string | null): boolean {
|
||||||
|
return parseBaseUrlParts(baseUrl)?.path.endsWith('/v1') ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBigModelCodingApiRoot(baseUrl?: string | null): boolean {
|
||||||
|
const parts = parseBaseUrlParts(baseUrl)
|
||||||
|
return parts?.host === 'open.bigmodel.cn' && parts.path === '/api/coding/paas/v4'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGoogleOpenAiCompatApiRoot(baseUrl?: string | null): boolean {
|
||||||
|
const parts = parseBaseUrlParts(baseUrl)
|
||||||
|
return parts?.host === 'generativelanguage.googleapis.com'
|
||||||
|
&& (parts.path === '/v1beta/openai' || parts.path === '/v1/openai')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVertexOpenAiCompatApiRoot(baseUrl?: string | null): boolean {
|
||||||
|
const parts = parseBaseUrlParts(baseUrl)
|
||||||
|
return !!parts
|
||||||
|
&& (parts.host === 'aiplatform.googleapis.com' || parts.host.endsWith('.aiplatform.googleapis.com') || parts.host.endsWith('-aiplatform.googleapis.com'))
|
||||||
|
&& parts.path.endsWith('/endpoints/openapi')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAiCompatibleBaseIncludesApiRoot(baseUrl?: string | null): boolean {
|
||||||
|
return baseUrlEndsWithV1Root(baseUrl)
|
||||||
|
|| baseUrlHasPathApiRoot(baseUrl)
|
||||||
|
|| isBigModelCodingApiRoot(baseUrl)
|
||||||
|
|| isGoogleOpenAiCompatApiRoot(baseUrl)
|
||||||
|
|| isVertexOpenAiCompatApiRoot(baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
function v1CompatibleBaseIncludesApiRoot(baseUrl?: string | null): boolean {
|
||||||
|
return baseUrlEndsWithV1Root(baseUrl) || baseUrlHasPathApiRoot(baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
function stripV1PrefixForApiRoot(path: string): string {
|
function stripV1PrefixForApiRoot(path: string): string {
|
||||||
return path.replace(/^\/v1(?=\/)/i, '')
|
return path.replace(/^\/v1(?=\/)/i, '')
|
||||||
}
|
}
|
||||||
@@ -34,6 +80,10 @@ function isOpenAiCompatibleFormat(apiFormat: string): boolean {
|
|||||||
return apiFormat.startsWith('openai:') || apiFormat.startsWith('jina:')
|
return apiFormat.startsWith('openai:') || apiFormat.startsWith('jina:')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isClaudeCompatibleFormat(apiFormat: string): boolean {
|
||||||
|
return apiFormat === 'claude:messages'
|
||||||
|
}
|
||||||
|
|
||||||
export function getDefaultEndpointPath(params: {
|
export function getDefaultEndpointPath(params: {
|
||||||
apiFormat: string
|
apiFormat: string
|
||||||
providerType?: string | null
|
providerType?: string | null
|
||||||
@@ -62,7 +112,10 @@ export function getDefaultEndpointPath(params: {
|
|||||||
if (normalizedApiFormat === 'openai:responses' && isCodex) {
|
if (normalizedApiFormat === 'openai:responses' && isCodex) {
|
||||||
return '/responses'
|
return '/responses'
|
||||||
}
|
}
|
||||||
if (baseUrlEndsWithApiRoot(params.baseUrl) && isOpenAiCompatibleFormat(normalizedApiFormat)) {
|
if (openAiCompatibleBaseIncludesApiRoot(params.baseUrl) && isOpenAiCompatibleFormat(normalizedApiFormat)) {
|
||||||
|
return stripV1PrefixForApiRoot(defaultPath)
|
||||||
|
}
|
||||||
|
if (v1CompatibleBaseIncludesApiRoot(params.baseUrl) && isClaudeCompatibleFormat(normalizedApiFormat)) {
|
||||||
return stripV1PrefixForApiRoot(defaultPath)
|
return stripV1PrefixForApiRoot(defaultPath)
|
||||||
}
|
}
|
||||||
return defaultPath
|
return defaultPath
|
||||||
|
|||||||
Reference in New Issue
Block a user