mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 04:30:20 +08:00
Normalize endpoint API root handling
This commit is contained in:
@@ -3,6 +3,11 @@ use crate::handlers::admin::request::AdminGatewayProviderTransportSnapshot;
|
||||
use serde_json::json;
|
||||
|
||||
fn sample_openai_image_transport(provider_type: &str) -> AdminGatewayProviderTransportSnapshot {
|
||||
let base_url = if provider_type == "custom" {
|
||||
"https://grok.com/v1/"
|
||||
} else {
|
||||
"https://grok.com/"
|
||||
};
|
||||
AdminGatewayProviderTransportSnapshot {
|
||||
provider: crate::provider_transport::snapshot::GatewayProviderTransportProvider {
|
||||
id: "provider-1".to_string(),
|
||||
@@ -26,7 +31,7 @@ fn sample_openai_image_transport(provider_type: &str) -> AdminGatewayProviderTra
|
||||
api_family: None,
|
||||
endpoint_kind: None,
|
||||
is_active: true,
|
||||
base_url: "https://grok.com/".to_string(),
|
||||
base_url: base_url.to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
|
||||
@@ -271,7 +271,7 @@ async fn gateway_model_fetch_updates_key_and_syncs_provider_model_whitelist_asso
|
||||
.clone()
|
||||
.expect("execution runtime plan should be captured");
|
||||
assert_eq!(seen_plan.method, "GET");
|
||||
assert_eq!(seen_plan.url, "https://api.openai.example/v1/models");
|
||||
assert_eq!(seen_plan.url, "https://api.openai.example/models");
|
||||
assert_eq!(
|
||||
seen_plan.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer live-secret-api-key")
|
||||
@@ -322,7 +322,7 @@ async fn gateway_model_fetch_updates_key_and_syncs_provider_model_whitelist_asso
|
||||
let seen_upstream = Arc::new(Mutex::new(None::<SeenUpstreamRequest>));
|
||||
let seen_upstream_clone = Arc::clone(&seen_upstream);
|
||||
let upstream = Router::new().route(
|
||||
"/v1/models",
|
||||
"/models",
|
||||
any(move |request: Request| {
|
||||
let seen_upstream_inner = Arc::clone(&seen_upstream_clone);
|
||||
async move {
|
||||
@@ -538,7 +538,7 @@ async fn gateway_background_model_fetch_updates_key_and_syncs_provider_model_whi
|
||||
.expect("mutex should lock")
|
||||
.clone()
|
||||
.expect("execution runtime plan should be captured");
|
||||
assert_eq!(seen_plan.url, "https://api.openai.example/v1/models");
|
||||
assert_eq!(seen_plan.url, "https://api.openai.example/models");
|
||||
|
||||
background_tasks.shutdown().await;
|
||||
execution_runtime_handle.abort();
|
||||
|
||||
@@ -365,7 +365,7 @@ async fn gateway_executes_openai_chat_sync_upstream_stream_via_local_finalize_re
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider_catalog_provider()],
|
||||
vec![sample_provider_catalog_endpoint(
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![sample_provider_catalog_key()],
|
||||
));
|
||||
|
||||
@@ -126,7 +126,7 @@ fn sample_local_openai_endpoint() -> StoredProviderCatalogEndpoint {
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.example".to_string(),
|
||||
"https://api.openai.example/v1".to_string(),
|
||||
None,
|
||||
None,
|
||||
Some(2),
|
||||
|
||||
@@ -348,7 +348,7 @@ async fn gateway_executes_openai_chat_stream_via_local_decision_gate_without_exe
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let (provider_url, provider_handle) = start_server(provider).await;
|
||||
let mut primary_endpoint = sample_provider_catalog_endpoint();
|
||||
primary_endpoint.base_url = provider_url.clone();
|
||||
primary_endpoint.base_url = format!("{provider_url}/v1");
|
||||
backup_endpoint.base_url = "http://127.0.0.1:9".to_string();
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider_catalog_provider(), backup_provider],
|
||||
@@ -2300,7 +2300,7 @@ async fn gateway_retries_next_local_openai_chat_stream_candidate_after_retryable
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_requests[0].url,
|
||||
"https://api.openai.primary.example/v1/chat/completions"
|
||||
"https://api.openai.primary.example/chat/completions"
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_requests[0].authorization,
|
||||
@@ -2308,7 +2308,7 @@ async fn gateway_retries_next_local_openai_chat_stream_candidate_after_retryable
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_requests[1].url,
|
||||
"https://api.openai.backup.example/v1/chat/completions"
|
||||
"https://api.openai.backup.example/chat/completions"
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_requests[1].model,
|
||||
|
||||
@@ -1068,7 +1068,7 @@ async fn gateway_routes_openai_responses_stream_image_intent_to_openai_image_pla
|
||||
"responses-stream-image-bridge",
|
||||
"image-provider",
|
||||
"custom",
|
||||
"https://images.example.com",
|
||||
"https://images.example.com/v1",
|
||||
execution_runtime_url,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -234,7 +234,7 @@ async fn ai_execute_stream_pii_redaction_round_trip() {
|
||||
let seen_provider_request = Arc::new(Mutex::new(None::<SeenProviderStreamRequest>));
|
||||
let seen_provider_request_clone = Arc::clone(&seen_provider_request);
|
||||
let provider_app = Router::new().route(
|
||||
"/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
any(move |request: Request| {
|
||||
let seen_provider_request_inner = Arc::clone(&seen_provider_request_clone);
|
||||
async move {
|
||||
|
||||
@@ -378,7 +378,7 @@ async fn gateway_skips_unsupported_local_openai_chat_sync_candidate_before_tryin
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_request.url,
|
||||
"https://api.openai.backup.example/v1/chat/completions"
|
||||
"https://api.openai.backup.example/chat/completions"
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_request.model,
|
||||
@@ -1115,7 +1115,7 @@ async fn gateway_retries_next_local_openai_chat_sync_candidate_after_auth_failur
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_requests[0].url,
|
||||
"https://api.openai.primary.example/v1/chat/completions"
|
||||
"https://api.openai.primary.example/chat/completions"
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_requests[0].authorization,
|
||||
@@ -1123,7 +1123,7 @@ async fn gateway_retries_next_local_openai_chat_sync_candidate_after_auth_failur
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_requests[1].url,
|
||||
"https://api.openai.backup.example/v1/chat/completions"
|
||||
"https://api.openai.backup.example/chat/completions"
|
||||
);
|
||||
assert_eq!(
|
||||
seen_execution_runtime_requests[1].model,
|
||||
|
||||
@@ -233,7 +233,7 @@ async fn proxy_pii_redaction_local_openai_chat_runtime_masks_headers_and_restore
|
||||
let seen_provider_request = Arc::new(Mutex::new(None::<SeenProviderRequest>));
|
||||
let seen_provider_request_clone = Arc::clone(&seen_provider_request);
|
||||
let provider_app = Router::new().route(
|
||||
"/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
any(move |request: Request| {
|
||||
let seen_provider_request_inner = Arc::clone(&seen_provider_request_clone);
|
||||
async move {
|
||||
@@ -491,7 +491,7 @@ async fn gateway_executes_openai_chat_sync_via_local_decision_gate_without_execu
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.example".to_string(),
|
||||
"https://api.openai.example/v1".to_string(),
|
||||
None,
|
||||
None,
|
||||
Some(2),
|
||||
@@ -669,7 +669,7 @@ async fn gateway_executes_openai_chat_sync_via_local_decision_gate_without_execu
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let (provider_url, provider_handle) = start_server(provider).await;
|
||||
let mut primary_endpoint = sample_provider_catalog_endpoint();
|
||||
primary_endpoint.base_url = provider_url.clone();
|
||||
primary_endpoint.base_url = format!("{provider_url}/v1");
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
vec![sample_provider_catalog_provider(), backup_provider],
|
||||
{
|
||||
@@ -871,7 +871,7 @@ async fn gateway_executes_openai_chat_sync_with_regex_model_mapping_in_execution
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.example".to_string(),
|
||||
"https://api.openai.example/v1".to_string(),
|
||||
None,
|
||||
None,
|
||||
Some(2),
|
||||
|
||||
@@ -308,7 +308,7 @@ async fn run_sync_redaction_case_with_system_config(
|
||||
let seen_provider_request = Arc::new(Mutex::new(None::<SeenProviderRequest>));
|
||||
let seen_provider_request_clone = Arc::clone(&seen_provider_request);
|
||||
let provider_app = Router::new().route(
|
||||
"/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
any(move |request: Request| {
|
||||
let seen_provider_request_inner = Arc::clone(&seen_provider_request_clone);
|
||||
async move {
|
||||
@@ -631,7 +631,7 @@ async fn ai_execute_pii_redaction_restores_executed_candidate_session_after_late
|
||||
let seen_provider_request = Arc::new(Mutex::new(None::<SeenProviderRequest>));
|
||||
let seen_provider_request_clone = Arc::clone(&seen_provider_request);
|
||||
let provider_app = Router::new().route(
|
||||
"/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
any(move |request: Request| {
|
||||
let seen_provider_request_inner = Arc::clone(&seen_provider_request_clone);
|
||||
async move {
|
||||
|
||||
@@ -486,7 +486,7 @@ async fn gateway_converts_gemini_image_sync_to_openai_image_provider_impl() {
|
||||
StoredProviderCatalogProvider::new(
|
||||
"provider-openai-image-bridge-1".to_string(),
|
||||
"openai".to_string(),
|
||||
Some("https://api.openai.com".to_string()),
|
||||
Some("https://api.openai.com/v1".to_string()),
|
||||
"openai".to_string(),
|
||||
)
|
||||
.expect("provider should build")
|
||||
@@ -514,7 +514,7 @@ async fn gateway_converts_gemini_image_sync_to_openai_image_provider_impl() {
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.com".to_string(),
|
||||
"https://api.openai.com/v1".to_string(),
|
||||
None,
|
||||
None,
|
||||
Some(2),
|
||||
|
||||
@@ -540,7 +540,7 @@ async fn gateway_cancels_openai_video_task_via_internal_async_task_endpoint() {
|
||||
"format_converted": false
|
||||
},
|
||||
"transport": {
|
||||
"upstream_base_url": "https://api.openai.example",
|
||||
"upstream_base_url": "https://api.openai.example/v1",
|
||||
"provider_name": "openai-video",
|
||||
"provider_id": "provider-1",
|
||||
"endpoint_id": "endpoint-1",
|
||||
@@ -670,6 +670,7 @@ async fn gateway_cancels_openai_video_task_via_internal_async_task_endpoint_with
|
||||
);
|
||||
task.external_task_id = Some("ext-video-task-123".to_string());
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let upstream_api_root = format!("{upstream_url}/v1");
|
||||
task.request_metadata = Some(json!({
|
||||
"rust_local_snapshot": {
|
||||
"OpenAi": {
|
||||
@@ -702,7 +703,7 @@ async fn gateway_cancels_openai_video_task_via_internal_async_task_endpoint_with
|
||||
"format_converted": false
|
||||
},
|
||||
"transport": {
|
||||
"upstream_base_url": upstream_url,
|
||||
"upstream_base_url": upstream_api_root,
|
||||
"provider_name": "openai-video",
|
||||
"provider_id": "provider-1",
|
||||
"endpoint_id": "endpoint-1",
|
||||
|
||||
@@ -172,7 +172,7 @@ async fn gateway_exposes_request_id_header_for_local_execution_response() {
|
||||
]));
|
||||
|
||||
let provider = Router::new().route(
|
||||
"/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
any(|_request: Request| async move {
|
||||
Json(json!({
|
||||
"id": "chatcmpl-direct-audit-123",
|
||||
|
||||
@@ -847,7 +847,7 @@ async fn gateway_fetches_allowed_models_immediately_when_creating_key_with_auto_
|
||||
"endpoint-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![],
|
||||
));
|
||||
@@ -1662,7 +1662,7 @@ async fn gateway_overwrites_allowed_models_immediately_when_enabling_auto_fetch(
|
||||
"endpoint-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![key],
|
||||
));
|
||||
@@ -1770,7 +1770,7 @@ async fn gateway_fetches_allowed_models_immediately_when_enabling_auto_fetch_fro
|
||||
"endpoint-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![key],
|
||||
));
|
||||
@@ -1878,7 +1878,7 @@ async fn gateway_refreshes_allowed_models_when_updating_include_patterns_with_au
|
||||
"endpoint-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![key],
|
||||
));
|
||||
@@ -1982,7 +1982,7 @@ async fn gateway_refreshes_allowed_models_when_updating_exclude_patterns_with_au
|
||||
"endpoint-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![key],
|
||||
));
|
||||
|
||||
@@ -175,7 +175,7 @@ async fn gateway_handles_admin_provider_query_models_fetches_upstream_for_select
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.example".to_string(),
|
||||
"https://api.openai.example/v1".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -432,7 +432,7 @@ async fn gateway_handles_admin_provider_query_models_with_openai_responses_endpo
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.example".to_string(),
|
||||
"https://api.openai.example/v1".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -649,7 +649,7 @@ async fn gateway_handles_admin_provider_query_models_respecting_key_api_formats(
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.example".to_string(),
|
||||
"https://api.openai.example/v1".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -669,7 +669,7 @@ async fn gateway_handles_admin_provider_query_models_respecting_key_api_formats(
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.example".to_string(),
|
||||
"https://api.openai.example/v1".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -798,7 +798,7 @@ async fn gateway_handles_admin_provider_query_models_aggregating_active_keys() {
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.example".to_string(),
|
||||
"https://api.openai.example/v1".to_string(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
@@ -1007,7 +1007,7 @@ async fn gateway_handles_admin_provider_query_test_model_locally_with_trusted_ad
|
||||
"endpoint-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
"key-openai-primary",
|
||||
@@ -1066,7 +1066,7 @@ async fn gateway_handles_admin_provider_query_embedding_model_test() {
|
||||
assert_eq!(plan.key_id, "key-siliconflow-embedding");
|
||||
assert_eq!(plan.client_api_format, "openai:embedding");
|
||||
assert_eq!(plan.provider_api_format, "openai:embedding");
|
||||
assert_eq!(plan.url, "https://api.siliconflow.example/v1/embeddings");
|
||||
assert_eq!(plan.url, "https://api.siliconflow.example/embeddings");
|
||||
assert_eq!(plan.model_name.as_deref(), Some("Qwen/Qwen3-Embedding-4B"));
|
||||
assert!(!plan.stream);
|
||||
assert_eq!(
|
||||
@@ -1546,7 +1546,7 @@ async fn gateway_handles_admin_provider_query_jina_embedding_model_test() {
|
||||
assert_eq!(plan.key_id, "key-jina-embedding");
|
||||
assert_eq!(plan.client_api_format, "openai:embedding");
|
||||
assert_eq!(plan.provider_api_format, "jina:embedding");
|
||||
assert_eq!(plan.url, "https://api.jina.example/v1/embeddings");
|
||||
assert_eq!(plan.url, "https://api.jina.example/embeddings");
|
||||
assert_eq!(plan.model_name.as_deref(), Some("jina-embeddings-v3"));
|
||||
assert!(!plan.stream);
|
||||
assert_eq!(
|
||||
@@ -1775,7 +1775,7 @@ async fn gateway_handles_admin_provider_query_rerank_model_test() {
|
||||
assert_eq!(plan.key_id, "key-jina-rerank");
|
||||
assert_eq!(plan.client_api_format, "openai:rerank");
|
||||
assert_eq!(plan.provider_api_format, "jina:rerank");
|
||||
assert_eq!(plan.url, "https://api.jina.example/v1/rerank");
|
||||
assert_eq!(plan.url, "https://api.jina.example/rerank");
|
||||
assert_eq!(
|
||||
plan.model_name.as_deref(),
|
||||
Some("jina-reranker-v2-base-multilingual")
|
||||
@@ -2736,7 +2736,7 @@ async fn gateway_handles_admin_provider_query_test_model_failover_locally_with_t
|
||||
"endpoint-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![
|
||||
sample_key(
|
||||
@@ -3504,7 +3504,7 @@ async fn gateway_handles_non_kiro_multi_model_failover_locally() {
|
||||
"endpoint-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
"key-openai-primary",
|
||||
@@ -3799,7 +3799,7 @@ async fn gateway_handles_openai_image_test_model_locally() {
|
||||
"endpoint-openai-image",
|
||||
"provider-openai",
|
||||
"openai:image",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
"key-openai-image",
|
||||
@@ -4112,13 +4112,13 @@ async fn gateway_prefers_supported_non_kiro_endpoint_when_api_format_is_omitted(
|
||||
"endpoint-openai-cli",
|
||||
"provider-openai",
|
||||
"openai:responses",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
),
|
||||
sample_endpoint(
|
||||
"endpoint-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
),
|
||||
],
|
||||
vec![
|
||||
@@ -4215,7 +4215,7 @@ async fn gateway_prefers_transport_supported_non_kiro_endpoint_when_api_format_i
|
||||
"endpoint-openai-chat-unsupported",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
);
|
||||
unsupported_endpoint.header_rules = Some(json!({"invalid": true}));
|
||||
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
|
||||
@@ -4226,7 +4226,7 @@ async fn gateway_prefers_transport_supported_non_kiro_endpoint_when_api_format_i
|
||||
"endpoint-openai-chat-supported",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
),
|
||||
],
|
||||
vec![sample_key(
|
||||
@@ -4322,7 +4322,7 @@ async fn gateway_prefers_supported_non_kiro_endpoint_with_compatible_key_when_ap
|
||||
"endpoint-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
),
|
||||
],
|
||||
vec![sample_key(
|
||||
@@ -4411,13 +4411,13 @@ async fn gateway_uses_compatible_cli_endpoint_when_api_format_is_omitted() {
|
||||
"endpoint-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
),
|
||||
sample_endpoint(
|
||||
"endpoint-openai-cli",
|
||||
"provider-openai",
|
||||
"openai:responses",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
),
|
||||
],
|
||||
vec![sample_key(
|
||||
@@ -4503,14 +4503,14 @@ async fn gateway_uses_runnable_cli_endpoint_after_chat_preference_when_api_forma
|
||||
"endpoint-openai-chat-unsupported",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
);
|
||||
unsupported_chat_endpoint.header_rules = Some(json!({"invalid": true}));
|
||||
let cli_endpoint = sample_endpoint(
|
||||
"endpoint-openai-cli-runnable",
|
||||
"provider-openai",
|
||||
"openai:responses",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
);
|
||||
let mut shared_key = sample_key(
|
||||
"key-openai-shared",
|
||||
@@ -4604,7 +4604,7 @@ async fn gateway_handles_openai_responses_test_model_failover_locally() {
|
||||
"endpoint-openai-cli",
|
||||
"provider-openai",
|
||||
"openai:responses",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
"key-openai-cli",
|
||||
@@ -4698,7 +4698,7 @@ async fn gateway_handles_claude_cli_test_model_locally() {
|
||||
"endpoint-claude-cli",
|
||||
"provider-claude",
|
||||
"claude:messages",
|
||||
"https://api.anthropic.example",
|
||||
"https://api.anthropic.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
"key-claude-cli",
|
||||
@@ -4786,7 +4786,7 @@ async fn gateway_uses_compatible_claude_cli_endpoint_when_api_format_is_omitted(
|
||||
"endpoint-claude-cli",
|
||||
"provider-claude",
|
||||
"claude:messages",
|
||||
"https://api.anthropic.example",
|
||||
"https://api.anthropic.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
"key-claude-cli",
|
||||
@@ -4875,7 +4875,7 @@ async fn gateway_handles_claude_cli_test_model_failover_locally() {
|
||||
"endpoint-claude-cli",
|
||||
"provider-claude",
|
||||
"claude:messages",
|
||||
"https://api.anthropic.example",
|
||||
"https://api.anthropic.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
"key-claude-cli",
|
||||
@@ -5653,7 +5653,7 @@ async fn gateway_handles_admin_provider_query_test_model_failover_with_single_mo
|
||||
"endpoint-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![sample_key(
|
||||
"key-openai-alias",
|
||||
@@ -5766,7 +5766,7 @@ async fn gateway_retries_non_kiro_failover_after_http_error_without_message() {
|
||||
"endpoint-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![
|
||||
sample_key(
|
||||
@@ -5886,7 +5886,7 @@ async fn gateway_retries_non_kiro_failover_after_success_status_without_body() {
|
||||
"endpoint-openai-chat",
|
||||
"provider-openai",
|
||||
"openai:chat",
|
||||
"https://api.openai.example",
|
||||
"https://api.openai.example/v1",
|
||||
)],
|
||||
vec![
|
||||
sample_key(
|
||||
|
||||
@@ -2516,7 +2516,7 @@ async fn gateway_handles_admin_usage_curl_locally_with_trusted_admin_principal()
|
||||
assert_eq!(payload["method"], "POST");
|
||||
assert_eq!(
|
||||
payload["url"],
|
||||
"https://api.openai.example/v1/chat/completions"
|
||||
"https://api.openai.example/chat/completions"
|
||||
);
|
||||
assert_eq!(payload["headers"]["Content-Type"], "application/json");
|
||||
assert_eq!(payload["headers"]["Authorization"], "Bearer provider-real");
|
||||
@@ -2536,7 +2536,7 @@ async fn gateway_handles_admin_usage_curl_locally_with_trusted_admin_principal()
|
||||
);
|
||||
let curl = payload["curl"].as_str().expect("curl should be string");
|
||||
assert!(curl.contains("curl"));
|
||||
assert!(curl.contains("https://api.openai.example/v1/chat/completions"));
|
||||
assert!(curl.contains("https://api.openai.example/chat/completions"));
|
||||
assert!(curl.contains("Content-Type: application/json"));
|
||||
assert!(curl.contains("Authorization: Bearer provider-real"));
|
||||
assert!(curl.contains("\"model\":\"gpt-5-target\""));
|
||||
|
||||
@@ -569,7 +569,7 @@ async fn gateway_cancels_admin_video_task_locally_with_trusted_admin_principal()
|
||||
"format_converted": false
|
||||
},
|
||||
"transport": {
|
||||
"upstream_base_url": "https://api.openai.example",
|
||||
"upstream_base_url": "https://api.openai.example/v1",
|
||||
"provider_name": "openai-video",
|
||||
"provider_id": "provider-openai",
|
||||
"endpoint_id": "endpoint-1",
|
||||
|
||||
@@ -304,7 +304,7 @@ fn assert_embedding_execution_plan(plan: &ExecutionPlan) {
|
||||
assert_eq!(plan.client_api_format, "openai:embedding");
|
||||
assert_eq!(plan.provider_api_format, "openai:embedding");
|
||||
assert_eq!(plan.method, "POST");
|
||||
assert_eq!(plan.url, "https://api.openai.example/v1/embeddings");
|
||||
assert_eq!(plan.url, "https://api.openai.example/embeddings");
|
||||
assert_eq!(plan.model_name.as_deref(), Some("text-embedding-3-small"));
|
||||
let body = plan.body.json_body.as_ref().expect("json request body");
|
||||
assert_eq!(body["model"], "upstream-embedding");
|
||||
|
||||
@@ -112,7 +112,7 @@ fn assert_rerank_execution_plan(plan: &ExecutionPlan) {
|
||||
assert_eq!(plan.client_api_format, "openai:rerank");
|
||||
assert_eq!(plan.provider_api_format, "openai:rerank");
|
||||
assert_eq!(plan.method, "POST");
|
||||
assert_eq!(plan.url, "https://api.openai.example/v1/rerank");
|
||||
assert_eq!(plan.url, "https://api.openai.example/rerank");
|
||||
assert_eq!(plan.model_name.as_deref(), Some("bge-reranker-base"));
|
||||
let body = plan.body.json_body.as_ref().expect("json request body");
|
||||
assert_eq!(body["model"], "upstream-rerank");
|
||||
|
||||
@@ -1932,7 +1932,7 @@ async fn gateway_handles_public_test_connection_without_hitting_fallback_probe()
|
||||
let provider_hits = Arc::new(Mutex::new(0usize));
|
||||
let provider_hits_clone = Arc::clone(&provider_hits);
|
||||
let provider = Router::new().route(
|
||||
"/v1/chat/completions",
|
||||
"/chat/completions",
|
||||
any(move |request: Request| {
|
||||
let provider_hits_inner = Arc::clone(&provider_hits_clone);
|
||||
async move {
|
||||
|
||||
@@ -140,7 +140,7 @@ pub(super) fn sample_local_openai_endpoint() -> StoredProviderCatalogEndpoint {
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.example".to_string(),
|
||||
"https://api.openai.example/v1".to_string(),
|
||||
None,
|
||||
None,
|
||||
Some(2),
|
||||
|
||||
@@ -68,7 +68,7 @@ const OPENAI_SPEC: ProviderSpec = ProviderSpec {
|
||||
global_model_id: "global-model-openai-usage-pricing-1",
|
||||
global_model_name: "gpt-5",
|
||||
provider_model_name: "gpt-5-upstream",
|
||||
upstream_base_url: "https://api.openai.example",
|
||||
upstream_base_url: "https://api.openai.example/v1",
|
||||
upstream_secret: "sk-upstream-openai-usage-pricing",
|
||||
};
|
||||
|
||||
@@ -82,7 +82,7 @@ const CLAUDE_SPEC: ProviderSpec = ProviderSpec {
|
||||
global_model_id: "global-model-claude-usage-pricing-1",
|
||||
global_model_name: "claude-sonnet-4-5",
|
||||
provider_model_name: "claude-sonnet-4-5-upstream",
|
||||
upstream_base_url: "https://api.anthropic.example",
|
||||
upstream_base_url: "https://api.anthropic.example/v1",
|
||||
upstream_secret: "sk-upstream-claude-usage-pricing",
|
||||
};
|
||||
|
||||
|
||||
@@ -715,7 +715,7 @@ async fn gateway_executes_openai_video_remix_via_data_backed_local_follow_up_wit
|
||||
"format_converted": false
|
||||
},
|
||||
"transport": {
|
||||
"upstream_base_url": "https://api.openai.example",
|
||||
"upstream_base_url": "https://api.openai.example/v1",
|
||||
"provider_name": "openai-video",
|
||||
"provider_id": "provider-openai-video-local-1",
|
||||
"endpoint_id": "endpoint-openai-video-local-1",
|
||||
|
||||
@@ -69,7 +69,7 @@ async fn gateway_executes_openai_video_delete_via_reconstructed_data_backed_loca
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.example".to_string(),
|
||||
"https://api.openai.example/v1".to_string(),
|
||||
None,
|
||||
None,
|
||||
Some(2),
|
||||
|
||||
@@ -165,7 +165,7 @@ async fn gateway_background_video_task_poller_refreshes_due_openai_task_from_rep
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let repository = Arc::new(InMemoryVideoTaskRepository::default());
|
||||
repository
|
||||
.upsert(sample_due_openai_task("https://api.openai.example"))
|
||||
.upsert(sample_due_openai_task("https://api.openai.example/v1"))
|
||||
.await
|
||||
.expect("task upsert should succeed");
|
||||
|
||||
@@ -268,9 +268,10 @@ async fn gateway_background_video_task_poller_refreshes_due_openai_task_from_rep
|
||||
);
|
||||
|
||||
let (upstream_url, upstream_handle) = start_server(upstream).await;
|
||||
let upstream_api_root = format!("{upstream_url}/v1");
|
||||
let repository = Arc::new(InMemoryVideoTaskRepository::default());
|
||||
repository
|
||||
.upsert(sample_due_openai_task(&upstream_url))
|
||||
.upsert(sample_due_openai_task(&upstream_api_root))
|
||||
.await
|
||||
.expect("task upsert should succeed");
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ async fn gateway_executes_openai_video_content_from_reconstructed_data_task_with
|
||||
)
|
||||
.expect("endpoint should build")
|
||||
.with_transport_fields(
|
||||
"https://api.openai.example".to_string(),
|
||||
"https://api.openai.example/v1".to_string(),
|
||||
None,
|
||||
None,
|
||||
Some(2),
|
||||
|
||||
@@ -359,7 +359,7 @@ fn seed_captures_transport_metadata_from_execution_plan() {
|
||||
};
|
||||
assert_eq!(
|
||||
seed.transport.upstream_base_url,
|
||||
"https://api.openai.example"
|
||||
"https://api.openai.example/v1"
|
||||
);
|
||||
assert_eq!(seed.transport.provider_id, "provider-123");
|
||||
assert_eq!(seed.transport.endpoint_id, "endpoint-123");
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
UPDATE provider_endpoints e
|
||||
LEFT JOIN providers p ON p.id = e.provider_id
|
||||
SET e.base_url = CONCAT(
|
||||
TRIM(TRAILING '/' FROM SUBSTRING_INDEX(e.base_url, '?', 1)),
|
||||
CASE
|
||||
WHEN LOWER(TRIM(e.api_format)) IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
THEN '/v1beta'
|
||||
ELSE '/v1'
|
||||
END,
|
||||
IF(LOCATE('?', e.base_url) > 0, SUBSTRING(e.base_url, LOCATE('?', e.base_url)), '')
|
||||
)
|
||||
WHERE LOWER(TRIM(e.api_format)) IN (
|
||||
'openai:chat',
|
||||
'openai:responses',
|
||||
'openai:responses:compact',
|
||||
'openai:embedding',
|
||||
'openai:rerank',
|
||||
'openai:image',
|
||||
'openai:video',
|
||||
'jina:embedding',
|
||||
'jina:rerank',
|
||||
'claude:messages',
|
||||
'gemini:generate_content',
|
||||
'gemini:embedding',
|
||||
'gemini:video'
|
||||
)
|
||||
AND COALESCE(LOWER(TRIM(p.provider_type)), '') NOT IN (
|
||||
'codex',
|
||||
'chatgpt_web',
|
||||
'claude_code',
|
||||
'kiro',
|
||||
'gemini_cli',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'grok',
|
||||
'windsurf'
|
||||
)
|
||||
AND LOWER(TRIM(TRAILING '/' FROM SUBSTRING_INDEX(e.base_url, '?', 1))) NOT REGEXP '/v[0-9]+(beta[0-9]*)?(/|$)'
|
||||
AND (
|
||||
(
|
||||
LOWER(TRIM(e.api_format)) IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
AND LOWER(TRIM(COALESCE(e.custom_path, ''))) LIKE '/v1beta/%'
|
||||
)
|
||||
OR (
|
||||
LOWER(TRIM(e.api_format)) NOT IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
AND LOWER(TRIM(COALESCE(e.custom_path, ''))) LIKE '/v1/%'
|
||||
)
|
||||
OR COALESCE(TRIM(e.custom_path), '') = ''
|
||||
);
|
||||
|
||||
UPDATE provider_endpoints e
|
||||
LEFT JOIN providers p ON p.id = e.provider_id
|
||||
SET e.custom_path = CASE
|
||||
WHEN LOWER(TRIM(e.api_format)) = 'openai:chat'
|
||||
AND LOWER(TRIM(COALESCE(e.custom_path, ''))) = '/v1/chat/completions'
|
||||
THEN NULL
|
||||
WHEN LOWER(TRIM(e.api_format)) = 'openai:responses'
|
||||
AND LOWER(TRIM(COALESCE(e.custom_path, ''))) = '/v1/responses'
|
||||
THEN NULL
|
||||
WHEN LOWER(TRIM(e.api_format)) = 'openai:responses:compact'
|
||||
AND LOWER(TRIM(COALESCE(e.custom_path, ''))) = '/v1/responses/compact'
|
||||
THEN NULL
|
||||
WHEN LOWER(TRIM(e.api_format)) = 'claude:messages'
|
||||
AND LOWER(TRIM(COALESCE(e.custom_path, ''))) = '/v1/messages'
|
||||
THEN NULL
|
||||
WHEN LOWER(TRIM(e.api_format)) IN ('openai:embedding', 'jina:embedding')
|
||||
AND LOWER(TRIM(COALESCE(e.custom_path, ''))) = '/v1/embeddings'
|
||||
THEN NULL
|
||||
WHEN LOWER(TRIM(e.api_format)) IN ('openai:rerank', 'jina:rerank')
|
||||
AND LOWER(TRIM(COALESCE(e.custom_path, ''))) = '/v1/rerank'
|
||||
THEN NULL
|
||||
WHEN LOWER(TRIM(e.api_format)) = 'openai:image'
|
||||
AND LOWER(TRIM(COALESCE(e.custom_path, ''))) = '/v1/images/generations'
|
||||
THEN NULL
|
||||
WHEN LOWER(TRIM(e.api_format)) = 'openai:video'
|
||||
AND LOWER(TRIM(COALESCE(e.custom_path, ''))) = '/v1/videos'
|
||||
THEN NULL
|
||||
WHEN LOWER(TRIM(e.api_format)) = 'gemini:generate_content'
|
||||
AND LOWER(TRIM(COALESCE(e.custom_path, ''))) = '/v1beta/models/{model}:{action}'
|
||||
THEN NULL
|
||||
WHEN LOWER(TRIM(e.api_format)) = 'gemini:embedding'
|
||||
AND LOWER(TRIM(COALESCE(e.custom_path, ''))) IN ('/v1beta/models/{model}:embedcontent', '/v1beta/models/{model}:{action}')
|
||||
THEN NULL
|
||||
WHEN LOWER(TRIM(e.api_format)) = 'gemini:video'
|
||||
AND LOWER(TRIM(COALESCE(e.custom_path, ''))) = '/v1beta/models/{model}:predictlongrunning'
|
||||
THEN NULL
|
||||
WHEN LOWER(TRIM(e.api_format)) IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
THEN CONCAT('/', SUBSTRING(TRIM(e.custom_path), 9))
|
||||
ELSE CONCAT('/', SUBSTRING(TRIM(e.custom_path), 5))
|
||||
END
|
||||
WHERE LOWER(TRIM(e.api_format)) IN (
|
||||
'openai:chat',
|
||||
'openai:responses',
|
||||
'openai:responses:compact',
|
||||
'openai:embedding',
|
||||
'openai:rerank',
|
||||
'openai:image',
|
||||
'openai:video',
|
||||
'jina:embedding',
|
||||
'jina:rerank',
|
||||
'claude:messages',
|
||||
'gemini:generate_content',
|
||||
'gemini:embedding',
|
||||
'gemini:video'
|
||||
)
|
||||
AND COALESCE(LOWER(TRIM(p.provider_type)), '') NOT IN (
|
||||
'codex',
|
||||
'chatgpt_web',
|
||||
'claude_code',
|
||||
'kiro',
|
||||
'gemini_cli',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'grok',
|
||||
'windsurf'
|
||||
)
|
||||
AND (
|
||||
(
|
||||
LOWER(TRIM(e.api_format)) IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
AND LOWER(TRIM(COALESCE(e.custom_path, ''))) LIKE '/v1beta/%'
|
||||
)
|
||||
OR (
|
||||
LOWER(TRIM(e.api_format)) NOT IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
AND LOWER(TRIM(COALESCE(e.custom_path, ''))) LIKE '/v1/%'
|
||||
)
|
||||
)
|
||||
AND LOWER(TRIM(TRAILING '/' FROM SUBSTRING_INDEX(e.base_url, '?', 1))) REGEXP '/v[0-9]+(beta[0-9]*)?(/|$)';
|
||||
|
||||
UPDATE provider_endpoints
|
||||
SET custom_path = NULL
|
||||
WHERE custom_path IS NOT NULL
|
||||
AND TRIM(custom_path) = '';
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
WITH endpoint_url_parts AS (
|
||||
SELECT
|
||||
e.id,
|
||||
e.base_url,
|
||||
rtrim(split_part(e.base_url, '?', 1), '/') AS base_without_query,
|
||||
CASE
|
||||
WHEN position('?' IN e.base_url) > 0 THEN substring(e.base_url FROM position('?' IN e.base_url))
|
||||
ELSE ''
|
||||
END AS query_suffix,
|
||||
lower(trim(e.api_format)) AS normalized_api_format,
|
||||
lower(trim(coalesce(e.custom_path, ''))) AS normalized_custom_path,
|
||||
lower(rtrim(split_part(e.base_url, '?', 1), '/')) AS normalized_base,
|
||||
lower(trim(coalesce(p.provider_type, ''))) AS provider_type
|
||||
FROM public.provider_endpoints e
|
||||
LEFT JOIN public.providers p ON p.id = e.provider_id
|
||||
WHERE lower(trim(e.api_format)) IN (
|
||||
'openai:chat',
|
||||
'openai:responses',
|
||||
'openai:responses:compact',
|
||||
'openai:embedding',
|
||||
'openai:rerank',
|
||||
'openai:image',
|
||||
'openai:video',
|
||||
'jina:embedding',
|
||||
'jina:rerank',
|
||||
'claude:messages',
|
||||
'gemini:generate_content',
|
||||
'gemini:embedding',
|
||||
'gemini:video'
|
||||
)
|
||||
),
|
||||
endpoint_api_root_updates AS (
|
||||
SELECT
|
||||
id,
|
||||
base_without_query
|
||||
|| CASE
|
||||
WHEN normalized_api_format IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
THEN '/v1beta'
|
||||
ELSE '/v1'
|
||||
END
|
||||
|| query_suffix AS next_base_url
|
||||
FROM endpoint_url_parts
|
||||
WHERE provider_type NOT IN (
|
||||
'codex',
|
||||
'chatgpt_web',
|
||||
'claude_code',
|
||||
'kiro',
|
||||
'gemini_cli',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'grok',
|
||||
'windsurf'
|
||||
)
|
||||
AND normalized_base !~ '/v[0-9]+(beta[0-9]*)?(/|$)'
|
||||
AND (
|
||||
(
|
||||
normalized_api_format IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
AND normalized_custom_path LIKE '/v1beta/%'
|
||||
)
|
||||
OR (
|
||||
normalized_api_format NOT IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
AND normalized_custom_path LIKE '/v1/%'
|
||||
)
|
||||
OR normalized_custom_path = ''
|
||||
)
|
||||
)
|
||||
UPDATE public.provider_endpoints e
|
||||
SET base_url = u.next_base_url
|
||||
FROM endpoint_api_root_updates u
|
||||
WHERE e.id = u.id
|
||||
AND e.base_url IS DISTINCT FROM u.next_base_url;
|
||||
|
||||
UPDATE public.provider_endpoints e
|
||||
SET custom_path = CASE
|
||||
WHEN lower(trim(e.api_format)) = 'openai:chat'
|
||||
AND lower(trim(coalesce(e.custom_path, ''))) = '/v1/chat/completions'
|
||||
THEN NULL
|
||||
WHEN lower(trim(e.api_format)) = 'openai:responses'
|
||||
AND lower(trim(coalesce(e.custom_path, ''))) = '/v1/responses'
|
||||
THEN NULL
|
||||
WHEN lower(trim(e.api_format)) = 'openai:responses:compact'
|
||||
AND lower(trim(coalesce(e.custom_path, ''))) = '/v1/responses/compact'
|
||||
THEN NULL
|
||||
WHEN lower(trim(e.api_format)) = 'claude:messages'
|
||||
AND lower(trim(coalesce(e.custom_path, ''))) = '/v1/messages'
|
||||
THEN NULL
|
||||
WHEN lower(trim(e.api_format)) IN ('openai:embedding', 'jina:embedding')
|
||||
AND lower(trim(coalesce(e.custom_path, ''))) = '/v1/embeddings'
|
||||
THEN NULL
|
||||
WHEN lower(trim(e.api_format)) IN ('openai:rerank', 'jina:rerank')
|
||||
AND lower(trim(coalesce(e.custom_path, ''))) = '/v1/rerank'
|
||||
THEN NULL
|
||||
WHEN lower(trim(e.api_format)) = 'openai:image'
|
||||
AND lower(trim(coalesce(e.custom_path, ''))) = '/v1/images/generations'
|
||||
THEN NULL
|
||||
WHEN lower(trim(e.api_format)) = 'openai:video'
|
||||
AND lower(trim(coalesce(e.custom_path, ''))) = '/v1/videos'
|
||||
THEN NULL
|
||||
WHEN lower(trim(e.api_format)) = 'gemini:generate_content'
|
||||
AND lower(trim(coalesce(e.custom_path, ''))) = '/v1beta/models/{model}:{action}'
|
||||
THEN NULL
|
||||
WHEN lower(trim(e.api_format)) = 'gemini:embedding'
|
||||
AND lower(trim(coalesce(e.custom_path, ''))) IN ('/v1beta/models/{model}:embedcontent', '/v1beta/models/{model}:{action}')
|
||||
THEN NULL
|
||||
WHEN lower(trim(e.api_format)) = 'gemini:video'
|
||||
AND lower(trim(coalesce(e.custom_path, ''))) = '/v1beta/models/{model}:predictlongrunning'
|
||||
THEN NULL
|
||||
WHEN lower(trim(e.api_format)) IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
THEN regexp_replace(trim(e.custom_path), '^/v1beta(?=/)', '', 'i')
|
||||
ELSE regexp_replace(trim(e.custom_path), '^/v1(?=/)', '', 'i')
|
||||
END
|
||||
FROM public.providers p
|
||||
WHERE lower(trim(e.api_format)) IN (
|
||||
'openai:chat',
|
||||
'openai:responses',
|
||||
'openai:responses:compact',
|
||||
'openai:embedding',
|
||||
'openai:rerank',
|
||||
'openai:image',
|
||||
'openai:video',
|
||||
'jina:embedding',
|
||||
'jina:rerank',
|
||||
'claude:messages',
|
||||
'gemini:generate_content',
|
||||
'gemini:embedding',
|
||||
'gemini:video'
|
||||
)
|
||||
AND p.id = e.provider_id
|
||||
AND lower(trim(coalesce(p.provider_type, ''))) NOT IN (
|
||||
'codex',
|
||||
'chatgpt_web',
|
||||
'claude_code',
|
||||
'kiro',
|
||||
'gemini_cli',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'grok',
|
||||
'windsurf'
|
||||
)
|
||||
AND (
|
||||
(
|
||||
lower(trim(e.api_format)) IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
AND lower(trim(coalesce(e.custom_path, ''))) LIKE '/v1beta/%'
|
||||
)
|
||||
OR (
|
||||
lower(trim(e.api_format)) NOT IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
AND lower(trim(coalesce(e.custom_path, ''))) LIKE '/v1/%'
|
||||
)
|
||||
)
|
||||
AND lower(rtrim(split_part(e.base_url, '?', 1), '/')) ~ '/v[0-9]+(beta[0-9]*)?(/|$)';
|
||||
|
||||
UPDATE public.provider_endpoints
|
||||
SET custom_path = NULL
|
||||
WHERE custom_path IS NOT NULL
|
||||
AND trim(custom_path) = '';
|
||||
@@ -0,0 +1,177 @@
|
||||
WITH endpoint_url_parts AS (
|
||||
SELECT
|
||||
e.id,
|
||||
CASE
|
||||
WHEN instr(e.base_url, '?') > 0 THEN rtrim(substr(e.base_url, 1, instr(e.base_url, '?') - 1), '/')
|
||||
ELSE rtrim(e.base_url, '/')
|
||||
END AS base_without_query,
|
||||
CASE
|
||||
WHEN instr(e.base_url, '?') > 0 THEN substr(e.base_url, instr(e.base_url, '?'))
|
||||
ELSE ''
|
||||
END AS query_suffix,
|
||||
lower(trim(e.api_format)) AS normalized_api_format,
|
||||
lower(trim(coalesce(e.custom_path, ''))) AS normalized_custom_path,
|
||||
lower(CASE
|
||||
WHEN instr(e.base_url, '?') > 0 THEN rtrim(substr(e.base_url, 1, instr(e.base_url, '?') - 1), '/')
|
||||
ELSE rtrim(e.base_url, '/')
|
||||
END) AS normalized_base,
|
||||
lower(trim(coalesce(p.provider_type, ''))) AS provider_type
|
||||
FROM provider_endpoints e
|
||||
LEFT JOIN providers p ON p.id = e.provider_id
|
||||
WHERE lower(trim(e.api_format)) IN (
|
||||
'openai:chat',
|
||||
'openai:responses',
|
||||
'openai:responses:compact',
|
||||
'openai:embedding',
|
||||
'openai:rerank',
|
||||
'openai:image',
|
||||
'openai:video',
|
||||
'jina:embedding',
|
||||
'jina:rerank',
|
||||
'claude:messages',
|
||||
'gemini:generate_content',
|
||||
'gemini:embedding',
|
||||
'gemini:video'
|
||||
)
|
||||
),
|
||||
endpoint_api_root_updates AS (
|
||||
SELECT
|
||||
id,
|
||||
base_without_query
|
||||
|| CASE
|
||||
WHEN normalized_api_format IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
THEN '/v1beta'
|
||||
ELSE '/v1'
|
||||
END
|
||||
|| query_suffix AS next_base_url
|
||||
FROM endpoint_url_parts
|
||||
WHERE provider_type NOT IN (
|
||||
'codex',
|
||||
'chatgpt_web',
|
||||
'claude_code',
|
||||
'kiro',
|
||||
'gemini_cli',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'grok',
|
||||
'windsurf'
|
||||
)
|
||||
AND normalized_base NOT GLOB '*/v[0-9]'
|
||||
AND normalized_base NOT GLOB '*/v[0-9][0-9]'
|
||||
AND normalized_base NOT GLOB '*/v[0-9]/*'
|
||||
AND normalized_base NOT GLOB '*/v[0-9][0-9]/*'
|
||||
AND normalized_base NOT GLOB '*/v[0-9]beta*'
|
||||
AND normalized_base NOT GLOB '*/v[0-9][0-9]beta*'
|
||||
AND (
|
||||
(
|
||||
normalized_api_format IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
AND normalized_custom_path LIKE '/v1beta/%'
|
||||
)
|
||||
OR (
|
||||
normalized_api_format NOT IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
AND normalized_custom_path LIKE '/v1/%'
|
||||
)
|
||||
OR normalized_custom_path = ''
|
||||
)
|
||||
)
|
||||
UPDATE provider_endpoints
|
||||
SET base_url = (
|
||||
SELECT next_base_url
|
||||
FROM endpoint_api_root_updates
|
||||
WHERE endpoint_api_root_updates.id = provider_endpoints.id
|
||||
)
|
||||
WHERE id IN (SELECT id FROM endpoint_api_root_updates);
|
||||
|
||||
UPDATE provider_endpoints
|
||||
SET custom_path = CASE
|
||||
WHEN lower(trim(api_format)) = 'openai:chat'
|
||||
AND lower(trim(coalesce(custom_path, ''))) = '/v1/chat/completions'
|
||||
THEN NULL
|
||||
WHEN lower(trim(api_format)) = 'openai:responses'
|
||||
AND lower(trim(coalesce(custom_path, ''))) = '/v1/responses'
|
||||
THEN NULL
|
||||
WHEN lower(trim(api_format)) = 'openai:responses:compact'
|
||||
AND lower(trim(coalesce(custom_path, ''))) = '/v1/responses/compact'
|
||||
THEN NULL
|
||||
WHEN lower(trim(api_format)) = 'claude:messages'
|
||||
AND lower(trim(coalesce(custom_path, ''))) = '/v1/messages'
|
||||
THEN NULL
|
||||
WHEN lower(trim(api_format)) IN ('openai:embedding', 'jina:embedding')
|
||||
AND lower(trim(coalesce(custom_path, ''))) = '/v1/embeddings'
|
||||
THEN NULL
|
||||
WHEN lower(trim(api_format)) IN ('openai:rerank', 'jina:rerank')
|
||||
AND lower(trim(coalesce(custom_path, ''))) = '/v1/rerank'
|
||||
THEN NULL
|
||||
WHEN lower(trim(api_format)) = 'openai:image'
|
||||
AND lower(trim(coalesce(custom_path, ''))) = '/v1/images/generations'
|
||||
THEN NULL
|
||||
WHEN lower(trim(api_format)) = 'openai:video'
|
||||
AND lower(trim(coalesce(custom_path, ''))) = '/v1/videos'
|
||||
THEN NULL
|
||||
WHEN lower(trim(api_format)) = 'gemini:generate_content'
|
||||
AND lower(trim(coalesce(custom_path, ''))) = '/v1beta/models/{model}:{action}'
|
||||
THEN NULL
|
||||
WHEN lower(trim(api_format)) = 'gemini:embedding'
|
||||
AND lower(trim(coalesce(custom_path, ''))) IN ('/v1beta/models/{model}:embedcontent', '/v1beta/models/{model}:{action}')
|
||||
THEN NULL
|
||||
WHEN lower(trim(api_format)) = 'gemini:video'
|
||||
AND lower(trim(coalesce(custom_path, ''))) = '/v1beta/models/{model}:predictlongrunning'
|
||||
THEN NULL
|
||||
WHEN lower(trim(api_format)) IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
THEN '/' || substr(trim(custom_path), 9)
|
||||
ELSE '/' || substr(trim(custom_path), 5)
|
||||
END
|
||||
WHERE lower(trim(api_format)) IN (
|
||||
'openai:chat',
|
||||
'openai:responses',
|
||||
'openai:responses:compact',
|
||||
'openai:embedding',
|
||||
'openai:rerank',
|
||||
'openai:image',
|
||||
'openai:video',
|
||||
'jina:embedding',
|
||||
'jina:rerank',
|
||||
'claude:messages',
|
||||
'gemini:generate_content',
|
||||
'gemini:embedding',
|
||||
'gemini:video'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM providers p
|
||||
WHERE p.id = provider_endpoints.provider_id
|
||||
AND lower(trim(coalesce(p.provider_type, ''))) IN (
|
||||
'codex',
|
||||
'chatgpt_web',
|
||||
'claude_code',
|
||||
'kiro',
|
||||
'gemini_cli',
|
||||
'vertex_ai',
|
||||
'antigravity',
|
||||
'grok',
|
||||
'windsurf'
|
||||
)
|
||||
)
|
||||
AND (
|
||||
(
|
||||
lower(trim(api_format)) IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
AND lower(trim(coalesce(custom_path, ''))) LIKE '/v1beta/%'
|
||||
)
|
||||
OR (
|
||||
lower(trim(api_format)) NOT IN ('gemini:generate_content', 'gemini:embedding', 'gemini:video')
|
||||
AND lower(trim(coalesce(custom_path, ''))) LIKE '/v1/%'
|
||||
)
|
||||
)
|
||||
AND (
|
||||
lower(rtrim(CASE WHEN instr(base_url, '?') > 0 THEN substr(base_url, 1, instr(base_url, '?') - 1) ELSE base_url END, '/')) GLOB '*/v[0-9]'
|
||||
OR lower(rtrim(CASE WHEN instr(base_url, '?') > 0 THEN substr(base_url, 1, instr(base_url, '?') - 1) ELSE base_url END, '/')) GLOB '*/v[0-9][0-9]'
|
||||
OR lower(rtrim(CASE WHEN instr(base_url, '?') > 0 THEN substr(base_url, 1, instr(base_url, '?') - 1) ELSE base_url END, '/')) GLOB '*/v[0-9]/*'
|
||||
OR lower(rtrim(CASE WHEN instr(base_url, '?') > 0 THEN substr(base_url, 1, instr(base_url, '?') - 1) ELSE base_url END, '/')) GLOB '*/v[0-9][0-9]/*'
|
||||
OR lower(rtrim(CASE WHEN instr(base_url, '?') > 0 THEN substr(base_url, 1, instr(base_url, '?') - 1) ELSE base_url END, '/')) GLOB '*/v[0-9]beta*'
|
||||
OR lower(rtrim(CASE WHEN instr(base_url, '?') > 0 THEN substr(base_url, 1, instr(base_url, '?') - 1) ELSE base_url END, '/')) GLOB '*/v[0-9][0-9]beta*'
|
||||
);
|
||||
|
||||
UPDATE provider_endpoints
|
||||
SET custom_path = NULL
|
||||
WHERE custom_path IS NOT NULL
|
||||
AND trim(custom_path) = '';
|
||||
@@ -7,7 +7,7 @@ use tracing::info;
|
||||
// Generated by build.rs from schema/bootstrap/postgres.
|
||||
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
|
||||
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
|
||||
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260527000000;
|
||||
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260528000000;
|
||||
|
||||
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
|
||||
SELECT COUNT(*)::BIGINT
|
||||
|
||||
@@ -4,7 +4,9 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use sqlx::{migrate::AppliedMigration, query, query_scalar, Connection, PgConnection, PgPool};
|
||||
use sqlx::{
|
||||
migrate::AppliedMigration, query, query_scalar, Connection, PgConnection, PgPool, SqlitePool,
|
||||
};
|
||||
|
||||
use super::{
|
||||
all_up_migrations, pending_migrations_from_applied, prepare_database_for_startup,
|
||||
@@ -315,6 +317,7 @@ fn empty_database_snapshot_covers_current_cutoff_versions() {
|
||||
20260522000000,
|
||||
20260524000000,
|
||||
20260527000000,
|
||||
20260528000000,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -671,6 +674,7 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
|
||||
20260520010000,
|
||||
20260524000000,
|
||||
20260527000000,
|
||||
20260528000000,
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -696,10 +700,287 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
|
||||
20260520010000,
|
||||
20260524000000,
|
||||
20260527000000,
|
||||
20260528000000,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn endpoint_api_root_migration_moves_v1_from_stored_default_paths() {
|
||||
let pool = SqlitePool::connect("sqlite::memory:")
|
||||
.await
|
||||
.expect("sqlite pool should connect");
|
||||
query(
|
||||
r#"
|
||||
CREATE TABLE providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_type TEXT NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("providers table should be created");
|
||||
query(
|
||||
r#"
|
||||
CREATE TABLE provider_endpoints (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL,
|
||||
api_format TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
custom_path TEXT
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("provider_endpoints table should be created");
|
||||
query(
|
||||
r#"
|
||||
INSERT INTO providers (id, provider_type) VALUES
|
||||
('provider-custom', 'custom'),
|
||||
('provider-fixed-vertex', 'vertex_ai'),
|
||||
('provider-fixed-grok', 'grok');
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("providers fixture should insert");
|
||||
query(
|
||||
r#"
|
||||
INSERT INTO provider_endpoints (id, provider_id, api_format, base_url, custom_path) VALUES
|
||||
('openai-root', 'provider-custom', 'openai:chat', 'https://api.openai.example', NULL),
|
||||
('responses-root', 'provider-custom', 'openai:responses', 'https://responses.example.com', NULL),
|
||||
('responses-compact-root', 'provider-custom', 'openai:responses:compact', 'https://compact.example.com', NULL),
|
||||
('openai-path-root', 'provider-custom', 'openai:chat', 'https://proxy.example.com/api', NULL),
|
||||
('openai-old-default-path', 'provider-custom', 'openai:chat', 'https://proxy.example.com/api?tenant=demo', '/v1/chat/completions'),
|
||||
('openai-mismatched-custom-path', 'provider-custom', 'openai:chat', 'https://proxy.example.com/api', '/v1/responses'),
|
||||
('openai-v1-slash-old-default', 'provider-custom', 'openai:chat', 'https://already-versioned.example.com/v1/', '/v1/chat/completions'),
|
||||
('openai-v4-old-default-path', 'provider-custom', 'openai:chat', 'https://open.bigmodel.cn/api/coding/paas/v4', '/v1/chat/completions'),
|
||||
('embedding-root', 'provider-custom', 'openai:embedding', 'https://embedding.example.com', NULL),
|
||||
('embedding-v4-old-default-path', 'provider-custom', 'openai:embedding', 'https://embedding.example.com/api/v4', '/v1/embeddings'),
|
||||
('jina-embedding-root', 'provider-custom', 'jina:embedding', 'https://api.jina.example', NULL),
|
||||
('rerank-old-default-path', 'provider-custom', 'openai:rerank', 'https://rerank.example.com/api', '/v1/rerank'),
|
||||
('jina-rerank-old-default-path', 'provider-custom', 'jina:rerank', 'https://api.jina.example?tenant=demo', '/v1/rerank'),
|
||||
('image-root', 'provider-custom', 'openai:image', 'https://image.example.com', NULL),
|
||||
('image-edit-custom-path', 'provider-custom', 'openai:image', 'https://image.example.com/api', '/v1/images/edits'),
|
||||
('image-v4-edit-custom-path', 'provider-custom', 'openai:image', 'https://image.example.com/api/v4', '/v1/images/edits'),
|
||||
('video-root', 'provider-custom', 'openai:video', 'https://video.example.com', NULL),
|
||||
('video-v1beta-old-default-path', 'provider-custom', 'openai:video', 'https://video.example.com/api/v1beta', '/v1/videos'),
|
||||
('video-versioned-root', 'provider-custom', 'openai:video', 'https://ark.example.com/api/v3', NULL),
|
||||
('google-versioned-segment-root', 'provider-custom', 'openai:embedding', 'https://generativelanguage.googleapis.com/v1beta/openai', NULL),
|
||||
('gemini-root', 'provider-custom', 'gemini:generate_content', 'https://generativelanguage.googleapis.com', NULL),
|
||||
('gemini-old-default-path', 'provider-custom', 'gemini:generate_content', 'https://generativelanguage.googleapis.com?tenant=demo', '/v1beta/models/{model}:{action}'),
|
||||
('gemini-custom-path', 'provider-custom', 'gemini:generate_content', 'https://proxy.example.com/google', '/v1beta/models/gemini-upstream:generateContent'),
|
||||
('gemini-versioned-old-default', 'provider-custom', 'gemini:generate_content', 'https://generativelanguage.googleapis.com/v1beta', '/v1beta/models/{model}:{action}'),
|
||||
('gemini-embedding-root', 'provider-custom', 'gemini:embedding', 'https://generativelanguage.googleapis.com', NULL),
|
||||
('gemini-embedding-old-default', 'provider-custom', 'gemini:embedding', 'https://generativelanguage.googleapis.com', '/v1beta/models/{model}:embedContent'),
|
||||
('gemini-video-root', 'provider-custom', 'gemini:video', 'https://generativelanguage.googleapis.com', NULL),
|
||||
('gemini-video-versioned-old-default', 'provider-custom', 'gemini:video', 'https://generativelanguage.googleapis.com/v1beta', '/v1beta/models/{model}:predictLongRunning'),
|
||||
('fixed-vertex-gemini-root', 'provider-fixed-vertex', 'gemini:embedding', 'https://aiplatform.googleapis.com', NULL),
|
||||
('claude-path-root', 'provider-custom', 'claude:messages', 'https://proxy.example.com/anthropic', NULL),
|
||||
('claude-old-default-path', 'provider-custom', 'claude:messages', 'https://proxy.example.com/anthropic', '/v1/messages'),
|
||||
('fixed-grok-root', 'provider-fixed-grok', 'openai:chat', 'https://grok.com', NULL);
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("endpoint fixture should insert");
|
||||
|
||||
let migration = super::sqlite::MIGRATOR
|
||||
.iter()
|
||||
.find(|migration| migration.version == 20260528000000)
|
||||
.expect("endpoint API root migration should be embedded");
|
||||
sqlx::raw_sql(migration.sql.as_ref())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("endpoint API root migration should apply");
|
||||
|
||||
let rows: Vec<(String, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, base_url, custom_path FROM provider_endpoints ORDER BY id")
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("endpoint rows should load");
|
||||
let rows = rows
|
||||
.into_iter()
|
||||
.map(|(id, base_url, custom_path)| (id, (base_url, custom_path)))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
|
||||
assert_eq!(
|
||||
rows.get("openai-root"),
|
||||
Some(&("https://api.openai.example/v1".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("responses-root"),
|
||||
Some(&("https://responses.example.com/v1".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("responses-compact-root"),
|
||||
Some(&("https://compact.example.com/v1".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("openai-path-root"),
|
||||
Some(&("https://proxy.example.com/api/v1".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("openai-old-default-path"),
|
||||
Some(&(
|
||||
"https://proxy.example.com/api/v1?tenant=demo".to_string(),
|
||||
None
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("openai-mismatched-custom-path"),
|
||||
Some(&(
|
||||
"https://proxy.example.com/api/v1".to_string(),
|
||||
Some("/responses".to_string())
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("openai-v1-slash-old-default"),
|
||||
Some(&(
|
||||
"https://already-versioned.example.com/v1/".to_string(),
|
||||
None
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("openai-v4-old-default-path"),
|
||||
Some(&(
|
||||
"https://open.bigmodel.cn/api/coding/paas/v4".to_string(),
|
||||
None
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("embedding-root"),
|
||||
Some(&("https://embedding.example.com/v1".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("embedding-v4-old-default-path"),
|
||||
Some(&("https://embedding.example.com/api/v4".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("jina-embedding-root"),
|
||||
Some(&("https://api.jina.example/v1".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("rerank-old-default-path"),
|
||||
Some(&("https://rerank.example.com/api/v1".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("jina-rerank-old-default-path"),
|
||||
Some(&("https://api.jina.example/v1?tenant=demo".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("image-root"),
|
||||
Some(&("https://image.example.com/v1".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("image-edit-custom-path"),
|
||||
Some(&(
|
||||
"https://image.example.com/api/v1".to_string(),
|
||||
Some("/images/edits".to_string())
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("image-v4-edit-custom-path"),
|
||||
Some(&(
|
||||
"https://image.example.com/api/v4".to_string(),
|
||||
Some("/images/edits".to_string())
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("video-root"),
|
||||
Some(&("https://video.example.com/v1".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("video-v1beta-old-default-path"),
|
||||
Some(&("https://video.example.com/api/v1beta".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("video-versioned-root"),
|
||||
Some(&("https://ark.example.com/api/v3".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("google-versioned-segment-root"),
|
||||
Some(&(
|
||||
"https://generativelanguage.googleapis.com/v1beta/openai".to_string(),
|
||||
None
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("gemini-root"),
|
||||
Some(&(
|
||||
"https://generativelanguage.googleapis.com/v1beta".to_string(),
|
||||
None
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("gemini-old-default-path"),
|
||||
Some(&(
|
||||
"https://generativelanguage.googleapis.com/v1beta?tenant=demo".to_string(),
|
||||
None
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("gemini-custom-path"),
|
||||
Some(&(
|
||||
"https://proxy.example.com/google/v1beta".to_string(),
|
||||
Some("/models/gemini-upstream:generateContent".to_string())
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("gemini-versioned-old-default"),
|
||||
Some(&(
|
||||
"https://generativelanguage.googleapis.com/v1beta".to_string(),
|
||||
None
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("gemini-embedding-root"),
|
||||
Some(&(
|
||||
"https://generativelanguage.googleapis.com/v1beta".to_string(),
|
||||
None
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("gemini-embedding-old-default"),
|
||||
Some(&(
|
||||
"https://generativelanguage.googleapis.com/v1beta".to_string(),
|
||||
None
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("gemini-video-root"),
|
||||
Some(&(
|
||||
"https://generativelanguage.googleapis.com/v1beta".to_string(),
|
||||
None
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("gemini-video-versioned-old-default"),
|
||||
Some(&(
|
||||
"https://generativelanguage.googleapis.com/v1beta".to_string(),
|
||||
None
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("fixed-vertex-gemini-root"),
|
||||
Some(&("https://aiplatform.googleapis.com".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("claude-path-root"),
|
||||
Some(&("https://proxy.example.com/anthropic/v1".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("claude-old-default-path"),
|
||||
Some(&("https://proxy.example.com/anthropic/v1".to_string(), None))
|
||||
);
|
||||
assert_eq!(
|
||||
rows.get("fixed-grok-root"),
|
||||
Some(&("https://grok.com".to_string(), None))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_usage_schema_projects_upstream_stream_mode_for_all_drivers() {
|
||||
let mysql_baseline = include_str!("../../../migrations/mysql/20260403000000_baseline.sql");
|
||||
@@ -1222,6 +1503,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
|
||||
20260522000000,
|
||||
20260524000000,
|
||||
20260527000000,
|
||||
20260528000000,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -621,10 +621,10 @@ fn build_claude_models_url(base_url: &str) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut url = if trimmed_base_url.ends_with("/v1") {
|
||||
format!("{trimmed_base_url}/models")
|
||||
let mut url = if trimmed_base_url.ends_with("/models") {
|
||||
trimmed_base_url.to_string()
|
||||
} else {
|
||||
format!("{trimmed_base_url}/v1/models")
|
||||
format!("{trimmed_base_url}/models")
|
||||
};
|
||||
if let Some(query) = base_query.filter(|value| !value.trim().is_empty()) {
|
||||
url.push('?');
|
||||
@@ -991,7 +991,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
build_models_fetch_url("openai", "openai:responses", "https://example.com"),
|
||||
Some((
|
||||
"https://example.com/v1/models".to_string(),
|
||||
"https://example.com/models".to_string(),
|
||||
"openai:responses".to_string()
|
||||
))
|
||||
);
|
||||
@@ -1058,7 +1058,14 @@ mod tests {
|
||||
assert_eq!(
|
||||
build_models_fetch_url("openai", "openai:chat", "https://proxy.example.com"),
|
||||
Some((
|
||||
"https://proxy.example.com/v1/models".to_string(),
|
||||
"https://proxy.example.com/models".to_string(),
|
||||
"openai:chat".to_string()
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
build_models_fetch_url("openai", "openai:chat", "https://api.deepseek.com"),
|
||||
Some((
|
||||
"https://api.deepseek.com/models".to_string(),
|
||||
"openai:chat".to_string()
|
||||
))
|
||||
);
|
||||
@@ -1076,7 +1083,7 @@ mod tests {
|
||||
"https://proxy.example.com/api"
|
||||
),
|
||||
Some((
|
||||
"https://proxy.example.com/api/v1/models".to_string(),
|
||||
"https://proxy.example.com/api/models".to_string(),
|
||||
"claude:messages".to_string()
|
||||
))
|
||||
);
|
||||
|
||||
@@ -1679,11 +1679,11 @@ mod tests {
|
||||
executed_urls: Arc::clone(&executed_urls),
|
||||
routes: vec![
|
||||
(
|
||||
"https://bad.example.com/v1/models".to_string(),
|
||||
"https://bad.example.com/models".to_string(),
|
||||
Err("connection reset".to_string()),
|
||||
),
|
||||
(
|
||||
"https://chat.example.com/v1/models".to_string(),
|
||||
"https://chat.example.com/models".to_string(),
|
||||
Ok((
|
||||
200,
|
||||
json!({
|
||||
@@ -1692,7 +1692,7 @@ mod tests {
|
||||
)),
|
||||
),
|
||||
(
|
||||
"https://responses.example.com/v1/models".to_string(),
|
||||
"https://responses.example.com/models".to_string(),
|
||||
Ok((
|
||||
200,
|
||||
json!({
|
||||
|
||||
@@ -790,7 +790,7 @@ mod tests {
|
||||
.await
|
||||
.expect("plan");
|
||||
|
||||
assert_eq!(plan.url, "https://example.com/v1/models");
|
||||
assert_eq!(plan.url, "https://example.com/models");
|
||||
assert_eq!(
|
||||
plan.headers.get("user-agent").map(String::as_str),
|
||||
Some("openai-codex/1.0")
|
||||
@@ -813,7 +813,7 @@ mod tests {
|
||||
.await
|
||||
.expect("plan");
|
||||
|
||||
assert_eq!(plan.url, "https://example.com/v1/models");
|
||||
assert_eq!(plan.url, "https://example.com/models");
|
||||
assert_eq!(
|
||||
plan.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer secret")
|
||||
@@ -915,7 +915,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
plan.url,
|
||||
"https://example.com/v1/models?limit=100&after_id=cursor-1"
|
||||
"https://example.com/models?limit=100&after_id=cursor-1"
|
||||
);
|
||||
assert_eq!(
|
||||
plan.headers.get("anthropic-version").map(String::as_str),
|
||||
|
||||
@@ -132,7 +132,7 @@ mod tests {
|
||||
api_family: None,
|
||||
endpoint_kind: None,
|
||||
is_active: true,
|
||||
base_url: "https://api.openai.com".to_string(),
|
||||
base_url: "https://api.openai.com/v1".to_string(),
|
||||
header_rules: None,
|
||||
body_rules: None,
|
||||
max_retries: None,
|
||||
|
||||
@@ -17,7 +17,6 @@ use crate::snapshot::GatewayProviderTransportSnapshot;
|
||||
use crate::url::{
|
||||
build_claude_messages_url, build_gemini_content_url, build_openai_chat_url,
|
||||
build_openai_responses_url, build_passthrough_path_url, normalize_gemini_content_action_path,
|
||||
openai_compatible_base_includes_api_root,
|
||||
};
|
||||
use crate::vertex::{
|
||||
build_vertex_api_key_gemini_content_url, build_vertex_api_key_gemini_embedding_url,
|
||||
@@ -423,32 +422,18 @@ fn normalize_gemini_embedding_action_path(path: &str, batch: bool) -> String {
|
||||
}
|
||||
|
||||
fn build_provider_embedding_v1_url(upstream_base_url: &str, query: Option<&str>) -> Option<String> {
|
||||
build_provider_v1_url(upstream_base_url, "/embeddings", "/v1/embeddings", query)
|
||||
build_provider_api_root_url(upstream_base_url, "/embeddings", query)
|
||||
}
|
||||
|
||||
fn build_provider_rerank_v1_url(upstream_base_url: &str, query: Option<&str>) -> Option<String> {
|
||||
build_provider_v1_url(upstream_base_url, "/rerank", "/v1/rerank", query)
|
||||
build_provider_api_root_url(upstream_base_url, "/rerank", query)
|
||||
}
|
||||
|
||||
fn build_provider_v1_url(
|
||||
fn build_provider_api_root_url(
|
||||
upstream_base_url: &str,
|
||||
v1_path: &str,
|
||||
default_path: &str,
|
||||
path: &str,
|
||||
query: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let base_without_query = upstream_base_url
|
||||
.trim()
|
||||
.split_once('?')
|
||||
.map(|(base, _)| base)
|
||||
.unwrap_or_else(|| upstream_base_url.trim())
|
||||
.trim_end_matches('/');
|
||||
let path = if base_without_query.ends_with("/v1")
|
||||
|| openai_compatible_base_includes_api_root(base_without_query)
|
||||
{
|
||||
v1_path
|
||||
} else {
|
||||
default_path
|
||||
};
|
||||
build_passthrough_path_url(upstream_base_url, path, query, &[])
|
||||
}
|
||||
|
||||
@@ -1016,7 +1001,12 @@ mod tests {
|
||||
"https://api.openai.example/v1",
|
||||
None,
|
||||
);
|
||||
let jina = sample_transport("jina", "jina:embedding", "https://api.jina.example", None);
|
||||
let jina = sample_transport(
|
||||
"jina",
|
||||
"jina:embedding",
|
||||
"https://api.jina.example/v1",
|
||||
None,
|
||||
);
|
||||
let gemini = sample_transport(
|
||||
"gemini",
|
||||
"gemini:embedding",
|
||||
@@ -1221,7 +1211,7 @@ mod tests {
|
||||
"https://api.openai.example/v1",
|
||||
None,
|
||||
);
|
||||
let jina = sample_transport("jina", "jina:rerank", "https://api.jina.example", None);
|
||||
let jina = sample_transport("jina", "jina:rerank", "https://api.jina.example/v1", None);
|
||||
|
||||
assert_eq!(
|
||||
build_transport_request_url(
|
||||
|
||||
@@ -568,12 +568,12 @@ mod tests {
|
||||
#[test]
|
||||
fn plan_fallback_url_helpers_route_openai_surfaces() {
|
||||
assert_eq!(
|
||||
build_standard_plan_fallback_openai_chat_url("https://api.example.com", Some("x=1")),
|
||||
build_standard_plan_fallback_openai_chat_url("https://api.example.com/v1", Some("x=1")),
|
||||
"https://api.example.com/v1/chat/completions?x=1"
|
||||
);
|
||||
assert_eq!(
|
||||
build_standard_plan_fallback_openai_responses_url(
|
||||
"https://api.example.com",
|
||||
"https://api.example.com/v1",
|
||||
Some("x=1"),
|
||||
true,
|
||||
),
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::provider_types::is_codex_cli_backend_url;
|
||||
use url::form_urlencoded;
|
||||
use url::Url;
|
||||
|
||||
pub fn build_openai_chat_url(upstream_base_url: &str, query: Option<&str>) -> String {
|
||||
let (trimmed, base_query) = split_base_url_query(upstream_base_url);
|
||||
let trimmed = trimmed.trim_end_matches('/');
|
||||
let mut url = if openai_compatible_base_includes_api_root(trimmed) {
|
||||
format!("{trimmed}/chat/completions")
|
||||
} else {
|
||||
format!("{trimmed}/v1/chat/completions")
|
||||
};
|
||||
let mut url = format!("{trimmed}/chat/completions");
|
||||
append_merged_query(&mut url, base_query, None, query, &[]);
|
||||
url
|
||||
}
|
||||
@@ -28,14 +23,7 @@ pub fn build_openai_responses_url(
|
||||
} else {
|
||||
"/responses"
|
||||
};
|
||||
let mut url = if is_codex_cli_backend_url(trimmed)
|
||||
|| trimmed.ends_with("/codex")
|
||||
|| openai_compatible_base_includes_api_root(trimmed)
|
||||
{
|
||||
format!("{trimmed}{suffix}")
|
||||
} else {
|
||||
format!("{trimmed}/v1{suffix}")
|
||||
};
|
||||
let mut url = format!("{trimmed}{suffix}");
|
||||
append_merged_query(&mut url, base_query, None, query, &[]);
|
||||
url
|
||||
}
|
||||
@@ -50,10 +38,8 @@ pub fn build_openai_image_url(
|
||||
let suffix = openai_image_path_suffix(request_path);
|
||||
let mut url = if openai_image_base_includes_operation_path(trimmed) {
|
||||
trimmed.to_string()
|
||||
} else if openai_compatible_base_includes_api_root(trimmed) {
|
||||
format!("{trimmed}{suffix}")
|
||||
} else {
|
||||
format!("{trimmed}/v1{suffix}")
|
||||
format!("{trimmed}{suffix}")
|
||||
};
|
||||
append_merged_query(&mut url, base_query, None, query, &[]);
|
||||
url
|
||||
@@ -80,11 +66,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 {
|
||||
let (trimmed, base_query) = split_base_url_query(upstream_base_url);
|
||||
let trimmed = trimmed.trim_end_matches('/');
|
||||
let mut url = if trimmed.ends_with("/v1") {
|
||||
format!("{trimmed}/messages")
|
||||
} else {
|
||||
format!("{trimmed}/v1/messages")
|
||||
};
|
||||
let mut url = format!("{trimmed}/messages");
|
||||
append_merged_query(&mut url, base_query, None, query, &[]);
|
||||
url
|
||||
}
|
||||
@@ -230,10 +212,10 @@ pub fn build_openai_compatible_models_url(upstream_base_url: &str) -> Option<Str
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut url = if openai_compatible_base_includes_api_root(trimmed_base_url) {
|
||||
format!("{trimmed_base_url}/models")
|
||||
let mut url = if trimmed_base_url.ends_with("/models") {
|
||||
trimmed_base_url.to_string()
|
||||
} else {
|
||||
format!("{trimmed_base_url}/v1/models")
|
||||
format!("{trimmed_base_url}/models")
|
||||
};
|
||||
append_merged_query(&mut url, base_query, None, None, &[]);
|
||||
Some(url)
|
||||
@@ -500,12 +482,20 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
build_openai_chat_url("https://proxy.example.com", None),
|
||||
"https://proxy.example.com/v1/chat/completions"
|
||||
"https://proxy.example.com/chat/completions"
|
||||
);
|
||||
assert_eq!(
|
||||
build_openai_chat_url("https://api.deepseek.com", None),
|
||||
"https://api.deepseek.com/chat/completions"
|
||||
);
|
||||
assert_eq!(
|
||||
build_openai_responses_url("https://proxy.example.com/api", None, false),
|
||||
"https://proxy.example.com/api/responses"
|
||||
);
|
||||
assert_eq!(
|
||||
build_openai_responses_url("https://api.deepseek.com", None, false),
|
||||
"https://api.deepseek.com/responses"
|
||||
);
|
||||
assert_eq!(
|
||||
build_openai_image_url(
|
||||
"https://proxy.example.com/api",
|
||||
@@ -524,15 +514,15 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
build_claude_messages_url("https://proxy.example.com/api", None),
|
||||
"https://proxy.example.com/api/v1/messages"
|
||||
"https://proxy.example.com/api/messages"
|
||||
);
|
||||
assert_eq!(
|
||||
build_claude_messages_url("https://proxy.example.com/anthropic", None),
|
||||
"https://proxy.example.com/anthropic/v1/messages"
|
||||
"https://proxy.example.com/anthropic/messages"
|
||||
);
|
||||
assert_eq!(
|
||||
build_claude_messages_url("https://api.anthropic.example", None),
|
||||
"https://api.anthropic.example/v1/messages"
|
||||
"https://api.anthropic.example/messages"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -565,7 +555,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
build_openai_compatible_models_url("https://proxy.example.com").as_deref(),
|
||||
Some("https://proxy.example.com/v1/models")
|
||||
Some("https://proxy.example.com/models")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -592,7 +582,11 @@ mod tests {
|
||||
"https://api.openai.example/v1/images/generations?tenant=demo&trace=1"
|
||||
);
|
||||
assert_eq!(
|
||||
build_openai_image_url("https://api.openai.example", Some("/v1/images/edits"), None),
|
||||
build_openai_image_url(
|
||||
"https://api.openai.example/v1",
|
||||
Some("/v1/images/edits"),
|
||||
None
|
||||
),
|
||||
"https://api.openai.example/v1/images/edits"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ pub fn build_video_create_upstream_url(
|
||||
match family {
|
||||
ProviderVideoCreateFamily::OpenAi => build_passthrough_path_url(
|
||||
&transport.endpoint.base_url,
|
||||
request_path,
|
||||
openai_video_api_root_request_path(request_path),
|
||||
request_query,
|
||||
&[],
|
||||
),
|
||||
@@ -183,6 +183,14 @@ pub fn build_video_create_upstream_url(
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_video_api_root_request_path(request_path: &str) -> &str {
|
||||
if request_path.starts_with("/v1/") {
|
||||
&request_path[3..]
|
||||
} else {
|
||||
request_path
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_video_create_headers(
|
||||
input: ProviderVideoCreateHeadersInput<'_>,
|
||||
) -> Option<BTreeMap<String, String>> {
|
||||
@@ -427,6 +435,22 @@ mod tests {
|
||||
assert_eq!(body.get("model"), Some(&json!("upstream-video-model")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_openai_video_create_url_from_api_root_base() {
|
||||
let mut transport = sample_transport("openai:video", "bearer");
|
||||
transport.endpoint.base_url = "https://api.openai.example/v1".to_string();
|
||||
let url = build_video_create_upstream_url(
|
||||
&transport,
|
||||
"/v1/videos",
|
||||
Some("trace=1"),
|
||||
"sora-upstream",
|
||||
ProviderVideoCreateFamily::OpenAi,
|
||||
)
|
||||
.expect("url should build");
|
||||
|
||||
assert_eq!(url, "https://api.openai.example/v1/videos?trace=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_gemini_video_create_url_and_removes_client_key_query() {
|
||||
let transport = sample_transport("gemini:video", "api_key");
|
||||
|
||||
@@ -15,6 +15,14 @@ use crate::{
|
||||
DEFAULT_VIDEO_TASK_POLL_INTERVAL_SECONDS,
|
||||
};
|
||||
|
||||
fn openai_video_resource_url(api_root: &str, suffix: &str) -> String {
|
||||
format!(
|
||||
"{}/videos/{}",
|
||||
api_root.trim_end_matches('/'),
|
||||
suffix.trim_start_matches('/')
|
||||
)
|
||||
}
|
||||
|
||||
pub fn map_openai_stored_task_to_read_response(
|
||||
task: StoredVideoTask,
|
||||
) -> LocalVideoTaskReadResponse {
|
||||
@@ -187,10 +195,9 @@ impl OpenAiVideoTaskSeed {
|
||||
headers.remove("content-type");
|
||||
headers.remove("content-length");
|
||||
(
|
||||
format!(
|
||||
"{}/v1/videos/{}/content",
|
||||
self.transport.upstream_base_url.trim_end_matches('/'),
|
||||
self.upstream_task_id
|
||||
openai_video_resource_url(
|
||||
&self.transport.upstream_base_url,
|
||||
format!("{}/content", self.upstream_task_id).as_str(),
|
||||
),
|
||||
headers,
|
||||
)
|
||||
@@ -200,10 +207,9 @@ impl OpenAiVideoTaskSeed {
|
||||
headers.remove("content-type");
|
||||
headers.remove("content-length");
|
||||
(
|
||||
format!(
|
||||
"{}/v1/videos/{}/content?variant={variant}",
|
||||
self.transport.upstream_base_url.trim_end_matches('/'),
|
||||
self.upstream_task_id
|
||||
openai_video_resource_url(
|
||||
&self.transport.upstream_base_url,
|
||||
format!("{}/content?variant={variant}", self.upstream_task_id).as_str(),
|
||||
),
|
||||
headers,
|
||||
)
|
||||
@@ -322,10 +328,9 @@ impl OpenAiVideoTaskSeed {
|
||||
endpoint_id: self.transport.endpoint_id.clone(),
|
||||
key_id: self.transport.key_id.clone(),
|
||||
method: "DELETE".to_string(),
|
||||
url: format!(
|
||||
"{}/v1/videos/{}",
|
||||
self.transport.upstream_base_url.trim_end_matches('/'),
|
||||
self.upstream_task_id
|
||||
url: openai_video_resource_url(
|
||||
&self.transport.upstream_base_url,
|
||||
&self.upstream_task_id,
|
||||
),
|
||||
headers,
|
||||
content_type: None,
|
||||
@@ -384,10 +389,9 @@ impl OpenAiVideoTaskSeed {
|
||||
endpoint_id: self.transport.endpoint_id.clone(),
|
||||
key_id: self.transport.key_id.clone(),
|
||||
method: "GET".to_string(),
|
||||
url: format!(
|
||||
"{}/v1/videos/{}",
|
||||
self.transport.upstream_base_url.trim_end_matches('/'),
|
||||
self.upstream_task_id
|
||||
url: openai_video_resource_url(
|
||||
&self.transport.upstream_base_url,
|
||||
&self.upstream_task_id,
|
||||
),
|
||||
headers,
|
||||
content_type: None,
|
||||
@@ -448,10 +452,9 @@ impl OpenAiVideoTaskSeed {
|
||||
endpoint_id: self.transport.endpoint_id.clone(),
|
||||
key_id: self.transport.key_id.clone(),
|
||||
method: "DELETE".to_string(),
|
||||
url: format!(
|
||||
"{}/v1/videos/{}",
|
||||
self.transport.upstream_base_url.trim_end_matches('/'),
|
||||
self.upstream_task_id
|
||||
url: openai_video_resource_url(
|
||||
&self.transport.upstream_base_url,
|
||||
&self.upstream_task_id,
|
||||
),
|
||||
headers,
|
||||
content_type: None,
|
||||
@@ -547,10 +550,9 @@ impl OpenAiVideoTaskSeed {
|
||||
endpoint_id: self.transport.endpoint_id.clone(),
|
||||
key_id: self.transport.key_id.clone(),
|
||||
method: "POST".to_string(),
|
||||
url: format!(
|
||||
"{}/v1/videos/{}/remix",
|
||||
self.transport.upstream_base_url.trim_end_matches('/'),
|
||||
self.upstream_task_id
|
||||
url: openai_video_resource_url(
|
||||
&self.transport.upstream_base_url,
|
||||
format!("{}/remix", self.upstream_task_id).as_str(),
|
||||
),
|
||||
headers,
|
||||
content_type: Some(content_type),
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{
|
||||
impl LocalVideoTaskTransport {
|
||||
pub fn from_plan(plan: &ExecutionPlan) -> Option<Self> {
|
||||
let upstream_base_url = match plan.provider_api_format.as_str() {
|
||||
"openai:video" => plan.url.split("/v1/videos").next()?.to_string(),
|
||||
"openai:video" => trim_openai_video_resource_root(&plan.url)?,
|
||||
"gemini:video" => plan.url.split("/v1beta/").next()?.to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
@@ -55,6 +55,15 @@ impl LocalVideoTaskTransport {
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_openai_video_resource_root(url: &str) -> Option<String> {
|
||||
let base = url.split_once('?').map(|(base, _)| base).unwrap_or(url);
|
||||
let (root, suffix) = base.rsplit_once("/videos")?;
|
||||
if !suffix.is_empty() && !suffix.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
Some(root.to_string())
|
||||
}
|
||||
|
||||
impl LocalVideoTaskPersistence {
|
||||
pub fn from_report_context(report_context: &Map<String, Value>, plan: &ExecutionPlan) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -1050,7 +1050,7 @@ import { log } from '@/utils/logger'
|
||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
import EndpointConditionEditor from './EndpointConditionEditor.vue'
|
||||
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||
import { getDefaultEndpointPath, normalizeEndpointApiFormat } from './endpoint-default-paths'
|
||||
import { getDefaultEndpointBaseUrl, getDefaultEndpointPath, normalizeEndpointApiFormat } from './endpoint-default-paths'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import {
|
||||
createEndpoint,
|
||||
@@ -1886,6 +1886,15 @@ function getEndpointDefaultPath(endpoint: ProviderEndpoint): string {
|
||||
return getDefaultPath(endpoint.api_format, getEndpointEditState(endpoint.id)?.url ?? endpoint.base_url)
|
||||
}
|
||||
|
||||
function getNewEndpointBaseUrl(): string {
|
||||
const typedBaseUrl = newEndpoint.value.base_url.trim()
|
||||
if (typedBaseUrl) return typedBaseUrl
|
||||
return getDefaultEndpointBaseUrl({
|
||||
apiFormat: newEndpoint.value.api_format,
|
||||
baseUrl: props.provider?.website || '',
|
||||
})
|
||||
}
|
||||
|
||||
function getDisplayedPath(endpoint: ProviderEndpoint): string {
|
||||
return getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')
|
||||
}
|
||||
@@ -3117,8 +3126,8 @@ function getResponseHeaderValidationErrorForEndpoint(endpointId: string): string
|
||||
|
||||
// 新端点选择的格式的默认路径
|
||||
const newEndpointDefaultPath = computed(() => {
|
||||
// 使用填写的 base_url 或 provider 的 website 来判断是否是 Codex 端点
|
||||
const baseUrl = newEndpoint.value.base_url || props.provider?.website || ''
|
||||
// 使用填写的 base_url;留空时使用按格式规范化后的 provider website。
|
||||
const baseUrl = getNewEndpointBaseUrl()
|
||||
return getDefaultPath(newEndpoint.value.api_format, baseUrl)
|
||||
})
|
||||
|
||||
@@ -3362,8 +3371,8 @@ async function handleCycleUpstreamStream(endpoint: ProviderEndpoint) {
|
||||
async function handleAddEndpoint() {
|
||||
if (!props.provider || !newEndpoint.value.api_format) return
|
||||
|
||||
// 如果没有输入 base_url,使用提供商的 website 作为默认值
|
||||
const baseUrl = newEndpoint.value.base_url || props.provider.website
|
||||
// 如果没有输入 base_url,使用按格式规范化后的提供商 website 作为默认值。
|
||||
const baseUrl = getNewEndpointBaseUrl()
|
||||
if (!baseUrl) {
|
||||
showError('请输入 Base URL')
|
||||
return
|
||||
|
||||
+124
-10
@@ -1,29 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getDefaultEndpointPath } from '../endpoint-default-paths'
|
||||
import { getDefaultEndpointBaseUrl, getDefaultEndpointPath } from '../endpoint-default-paths'
|
||||
|
||||
const apiFormats = [
|
||||
{ value: 'openai:chat', default_path: '/v1/chat/completions' },
|
||||
{ value: 'gemini:generate_content', default_path: '/v1beta/models/{model}:{action}' },
|
||||
{ value: 'gemini:embedding', default_path: '/v1beta/models/{model}:{action}' },
|
||||
{ value: 'gemini:embedding', default_path: '/v1beta/models/{model}:embedContent' },
|
||||
{ value: 'gemini:video', default_path: '/v1beta/models/{model}:predictLongRunning' },
|
||||
{ value: 'openai:responses', default_path: '/v1/responses' },
|
||||
{ value: 'openai:embedding', default_path: '/v1/embeddings' },
|
||||
{ value: 'openai:rerank', default_path: '/v1/rerank' },
|
||||
{ value: 'openai:image', default_path: '/v1/images/generations' },
|
||||
{ value: 'openai:video', default_path: '/v1/videos' },
|
||||
{ value: 'jina:embedding', default_path: '/v1/embeddings' },
|
||||
{ value: 'jina:rerank', default_path: '/v1/rerank' },
|
||||
{ value: 'claude:messages', default_path: '/v1/messages' },
|
||||
]
|
||||
|
||||
describe('endpoint default paths', () => {
|
||||
it('uses Gemini Developer API paths for custom Gemini endpoints', () => {
|
||||
it('uses Gemini Developer API resource paths for custom Gemini endpoints', () => {
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'gemini:generate_content',
|
||||
providerType: 'custom',
|
||||
apiFormats,
|
||||
})).toBe('/v1beta/models/{model}:{action}')
|
||||
})).toBe('/models/{model}:{action}')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'gemini:embedding',
|
||||
providerType: 'custom',
|
||||
apiFormats,
|
||||
})).toBe('/v1beta/models/{model}:{action}')
|
||||
})).toBe('/models/{model}:embedContent')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'gemini:video',
|
||||
providerType: 'custom',
|
||||
apiFormats,
|
||||
})).toBe('/models/{model}:predictLongRunning')
|
||||
})
|
||||
|
||||
it('uses Vertex AI project/location paths for Vertex provider Gemini endpoints', () => {
|
||||
@@ -56,7 +68,7 @@ describe('endpoint default paths', () => {
|
||||
})).toBe('/responses')
|
||||
})
|
||||
|
||||
it('drops /v1 from OpenAI-compatible defaults when base URL includes a path', () => {
|
||||
it('drops /v1 from API-root defaults because base URL is the API root', () => {
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:chat',
|
||||
providerType: 'custom',
|
||||
@@ -71,6 +83,41 @@ describe('endpoint default paths', () => {
|
||||
apiFormats,
|
||||
})).toBe('/embeddings')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:rerank',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com/api?tenant=demo',
|
||||
apiFormats,
|
||||
})).toBe('/rerank')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:image',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com/api',
|
||||
apiFormats,
|
||||
})).toBe('/images/generations')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:video',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com/api',
|
||||
apiFormats,
|
||||
})).toBe('/videos')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'jina:embedding',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://api.jina.ai/v1',
|
||||
apiFormats,
|
||||
})).toBe('/embeddings')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'jina:rerank',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://api.jina.ai/v1',
|
||||
apiFormats,
|
||||
})).toBe('/rerank')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'openai:chat',
|
||||
providerType: 'custom',
|
||||
@@ -83,7 +130,7 @@ describe('endpoint default paths', () => {
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com',
|
||||
apiFormats,
|
||||
})).toBe('/v1/chat/completions')
|
||||
})).toBe('/chat/completions')
|
||||
})
|
||||
|
||||
it('drops /v1 from OpenAI-compatible defaults when base URL already includes a known API root', () => {
|
||||
@@ -102,7 +149,7 @@ describe('endpoint default paths', () => {
|
||||
})).toBe('/responses')
|
||||
})
|
||||
|
||||
it('keeps /v1 for Claude Messages defaults unless base URL already ends with v1', () => {
|
||||
it('drops /v1 from Claude Messages defaults because base URL is the API root', () => {
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'claude:messages',
|
||||
providerType: 'custom',
|
||||
@@ -115,13 +162,80 @@ describe('endpoint default paths', () => {
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com/api',
|
||||
apiFormats,
|
||||
})).toBe('/v1/messages')
|
||||
})).toBe('/messages')
|
||||
|
||||
expect(getDefaultEndpointPath({
|
||||
apiFormat: 'claude:messages',
|
||||
providerType: 'custom',
|
||||
baseUrl: 'https://proxy.example.com/anthropic',
|
||||
apiFormats,
|
||||
})).toBe('/v1/messages')
|
||||
})).toBe('/messages')
|
||||
})
|
||||
|
||||
it('defaults API-root base URLs to the format version when using a provider website', () => {
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:chat',
|
||||
baseUrl: 'https://api.openai.com',
|
||||
})).toBe('https://api.openai.com/v1')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:responses',
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode',
|
||||
})).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'claude:messages',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
})).toBe('https://api.anthropic.com/v1')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:embedding',
|
||||
baseUrl: 'https://api.openai.com',
|
||||
})).toBe('https://api.openai.com/v1')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:image',
|
||||
baseUrl: 'https://api.openai.com',
|
||||
})).toBe('https://api.openai.com/v1')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:video',
|
||||
baseUrl: 'https://api.openai.com',
|
||||
})).toBe('https://api.openai.com/v1')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'jina:embedding',
|
||||
baseUrl: 'https://api.jina.ai',
|
||||
})).toBe('https://api.jina.ai/v1')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'gemini:generate_content',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com',
|
||||
})).toBe('https://generativelanguage.googleapis.com/v1beta')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'gemini:embedding',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com',
|
||||
})).toBe('https://generativelanguage.googleapis.com/v1beta')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'gemini:video',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com',
|
||||
})).toBe('https://generativelanguage.googleapis.com/v1beta')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:chat',
|
||||
baseUrl: 'https://open.bigmodel.cn/api/coding/paas/v4',
|
||||
})).toBe('https://open.bigmodel.cn/api/coding/paas/v4')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:chat',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||
})).toBe('https://generativelanguage.googleapis.com/v1beta/openai')
|
||||
|
||||
expect(getDefaultEndpointBaseUrl({
|
||||
apiFormat: 'openai:chat',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
})).toBe('https://api.deepseek.com')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,11 +42,21 @@ function baseUrlEndsWithV1Root(baseUrl?: string | null): boolean {
|
||||
return parseBaseUrlParts(baseUrl)?.path.endsWith('/v1') ?? false
|
||||
}
|
||||
|
||||
function baseUrlHasVersionedApiRoot(baseUrl?: string | null): boolean {
|
||||
const path = parseBaseUrlParts(baseUrl)?.path || ''
|
||||
return /\/v\d+(?:beta\d*)?(?:\/|$)/i.test(path)
|
||||
}
|
||||
|
||||
function isBigModelCodingApiRoot(baseUrl?: string | null): boolean {
|
||||
const parts = parseBaseUrlParts(baseUrl)
|
||||
return parts?.host === 'open.bigmodel.cn' && parts.path === '/api/coding/paas/v4'
|
||||
}
|
||||
|
||||
function isDeepSeekApiRoot(baseUrl?: string | null): boolean {
|
||||
const parts = parseBaseUrlParts(baseUrl)
|
||||
return parts?.host === 'api.deepseek.com'
|
||||
}
|
||||
|
||||
function isGoogleOpenAiCompatApiRoot(baseUrl?: string | null): boolean {
|
||||
const parts = parseBaseUrlParts(baseUrl)
|
||||
return parts?.host === 'generativelanguage.googleapis.com'
|
||||
@@ -68,20 +78,84 @@ function openAiCompatibleBaseIncludesApiRoot(baseUrl?: string | null): boolean {
|
||||
|| isVertexOpenAiCompatApiRoot(baseUrl)
|
||||
}
|
||||
|
||||
function v1CompatibleBaseIncludesApiRoot(baseUrl?: string | null): boolean {
|
||||
return baseUrlEndsWithV1Root(baseUrl)
|
||||
}
|
||||
|
||||
function stripV1PrefixForApiRoot(path: string): string {
|
||||
return path.replace(/^\/v1(?=\/)/i, '')
|
||||
function stripVersionPrefixForApiRoot(path: string): string {
|
||||
return path.replace(/^\/v\d+(?:beta\d*)?(?=\/)/i, '')
|
||||
}
|
||||
|
||||
function isOpenAiCompatibleFormat(apiFormat: string): boolean {
|
||||
return apiFormat.startsWith('openai:') || apiFormat.startsWith('jina:')
|
||||
}
|
||||
|
||||
function isClaudeCompatibleFormat(apiFormat: string): boolean {
|
||||
return apiFormat === 'claude:messages'
|
||||
function usesVersionedApiRootByDefault(apiFormat: string): boolean {
|
||||
return apiFormat === 'openai:chat'
|
||||
|| apiFormat === 'openai:responses'
|
||||
|| apiFormat === 'openai:responses:compact'
|
||||
|| apiFormat === 'openai:embedding'
|
||||
|| apiFormat === 'openai:rerank'
|
||||
|| apiFormat === 'openai:image'
|
||||
|| apiFormat === 'openai:video'
|
||||
|| apiFormat === 'jina:embedding'
|
||||
|| apiFormat === 'jina:rerank'
|
||||
|| apiFormat === 'claude:messages'
|
||||
|| apiFormat === 'gemini:generate_content'
|
||||
|| apiFormat === 'gemini:embedding'
|
||||
|| apiFormat === 'gemini:video'
|
||||
}
|
||||
|
||||
function versionedApiRootSuffix(apiFormat: string): '/v1' | '/v1beta' {
|
||||
if (
|
||||
apiFormat === 'gemini:generate_content'
|
||||
|| apiFormat === 'gemini:embedding'
|
||||
|| apiFormat === 'gemini:video'
|
||||
) {
|
||||
return '/v1beta'
|
||||
}
|
||||
return '/v1'
|
||||
}
|
||||
|
||||
function skipsVersionedApiRootDefault(apiFormat: string, baseUrl: string): boolean {
|
||||
if (
|
||||
apiFormat === 'gemini:generate_content'
|
||||
|| apiFormat === 'gemini:embedding'
|
||||
|| apiFormat === 'gemini:video'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return isDeepSeekApiRoot(baseUrl)
|
||||
|| isBigModelCodingApiRoot(baseUrl)
|
||||
|| isGoogleOpenAiCompatApiRoot(baseUrl)
|
||||
|| isVertexOpenAiCompatApiRoot(baseUrl)
|
||||
}
|
||||
|
||||
function appendVersionedApiRoot(baseUrl: string, suffix: '/v1' | '/v1beta'): string {
|
||||
const raw = baseUrl.trim()
|
||||
if (!raw) return ''
|
||||
try {
|
||||
const parsed = new URL(raw)
|
||||
parsed.pathname = `${parsed.pathname.replace(/\/+$/, '')}${suffix}`
|
||||
return parsed.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
const [base, query] = raw.split('?', 2)
|
||||
const normalizedBase = base.replace(/\/+$/, '')
|
||||
return query === undefined ? `${normalizedBase}${suffix}` : `${normalizedBase}${suffix}?${query}`
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultEndpointBaseUrl(params: {
|
||||
apiFormat: string
|
||||
baseUrl?: string | null
|
||||
}): string {
|
||||
const normalizedApiFormat = normalizeEndpointApiFormat(params.apiFormat)
|
||||
const rawBaseUrl = (params.baseUrl || '').trim()
|
||||
if (!rawBaseUrl) return ''
|
||||
if (
|
||||
usesVersionedApiRootByDefault(normalizedApiFormat)
|
||||
&& !baseUrlHasVersionedApiRoot(rawBaseUrl)
|
||||
&& !skipsVersionedApiRootDefault(normalizedApiFormat, rawBaseUrl)
|
||||
) {
|
||||
return appendVersionedApiRoot(rawBaseUrl, versionedApiRootSuffix(normalizedApiFormat))
|
||||
}
|
||||
return rawBaseUrl
|
||||
}
|
||||
|
||||
export function getDefaultEndpointPath(params: {
|
||||
@@ -117,11 +191,11 @@ export function getDefaultEndpointPath(params: {
|
||||
if (normalizedApiFormat === 'openai:responses' && isCodex) {
|
||||
return '/responses'
|
||||
}
|
||||
if (openAiCompatibleBaseIncludesApiRoot(params.baseUrl) && isOpenAiCompatibleFormat(normalizedApiFormat)) {
|
||||
return stripV1PrefixForApiRoot(defaultPath)
|
||||
if (usesVersionedApiRootByDefault(normalizedApiFormat)) {
|
||||
return stripVersionPrefixForApiRoot(defaultPath)
|
||||
}
|
||||
if (v1CompatibleBaseIncludesApiRoot(params.baseUrl) && isClaudeCompatibleFormat(normalizedApiFormat)) {
|
||||
return stripV1PrefixForApiRoot(defaultPath)
|
||||
if (openAiCompatibleBaseIncludesApiRoot(params.baseUrl) && isOpenAiCompatibleFormat(normalizedApiFormat)) {
|
||||
return stripVersionPrefixForApiRoot(defaultPath)
|
||||
}
|
||||
return defaultPath
|
||||
}
|
||||
|
||||
@@ -708,7 +708,6 @@ function getMockEndpointExtras(apiFormat: string) {
|
||||
{ action: 'regex_replace', path: 'messages[0].content', pattern: '\\s+', replacement: ' ', flags: 'm', condition: { path: 'metadata.source', op: 'eq', value: 'internal' } }
|
||||
]
|
||||
} else if (normalizedFormat === 'openai:chat') {
|
||||
extras.custom_path = '/v1/chat/completions'
|
||||
extras.header_rules = [
|
||||
{ action: 'set', key: 'x-client', value: 'demo' }
|
||||
]
|
||||
@@ -720,13 +719,11 @@ function getMockEndpointExtras(apiFormat: string) {
|
||||
} else if (normalizedFormat === 'openai:responses') {
|
||||
extras.config = { upstream_stream_policy: 'force_non_stream' }
|
||||
} else if (normalizedFormat === 'openai:embedding') {
|
||||
extras.custom_path = '/v1/embeddings'
|
||||
extras.config = { route_kind: 'embedding' }
|
||||
} else if (normalizedFormat === 'openai:rerank' || normalizedFormat === 'jina:rerank') {
|
||||
extras.custom_path = '/v1/rerank'
|
||||
extras.config = { route_kind: 'rerank' }
|
||||
} else if (normalizedFormat === 'gemini:generate_content') {
|
||||
extras.custom_path = '/v1beta/models/gemini-3-pro-preview:generateContent'
|
||||
extras.custom_path = '/models/gemini-3-pro-preview:generateContent'
|
||||
extras.body_rules = [
|
||||
{ action: 'drop', path: 'metadata.debug' }
|
||||
]
|
||||
@@ -745,9 +742,9 @@ const MOCK_ENDPOINT_KEYS = [
|
||||
|
||||
// Mock Endpoints
|
||||
const MOCK_ENDPOINTS = [
|
||||
{ id: 'ep-001', provider_id: 'provider-001', provider_name: 'anthropic', api_format: 'claude:messages', base_url: 'https://api.anthropic.com', max_retries: 2, is_active: true, total_keys: 2, active_keys: 2, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString(), ...getMockEndpointExtras('claude:messages') },
|
||||
{ id: 'ep-002', provider_id: 'provider-002', provider_name: 'openai', api_format: 'openai:chat', base_url: 'https://api.openai.com', max_retries: 2, is_active: true, total_keys: 1, active_keys: 1, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString(), ...getMockEndpointExtras('openai:chat') },
|
||||
{ id: 'ep-003', provider_id: 'provider-003', provider_name: 'google', api_format: 'gemini:generate_content', base_url: 'https://generativelanguage.googleapis.com', max_retries: 2, is_active: true, total_keys: 1, active_keys: 1, created_at: '2024-01-15T00:00:00Z', updated_at: new Date().toISOString(), ...getMockEndpointExtras('gemini:generate_content') }
|
||||
{ id: 'ep-001', provider_id: 'provider-001', provider_name: 'anthropic', api_format: 'claude:messages', base_url: 'https://api.anthropic.com/v1', max_retries: 2, is_active: true, total_keys: 2, active_keys: 2, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString(), ...getMockEndpointExtras('claude:messages') },
|
||||
{ id: 'ep-002', provider_id: 'provider-002', provider_name: 'openai', api_format: 'openai:chat', base_url: 'https://api.openai.com/v1', max_retries: 2, is_active: true, total_keys: 1, active_keys: 1, created_at: '2024-01-01T00:00:00Z', updated_at: new Date().toISOString(), ...getMockEndpointExtras('openai:chat') },
|
||||
{ id: 'ep-003', provider_id: 'provider-003', provider_name: 'google', api_format: 'gemini:generate_content', base_url: 'https://generativelanguage.googleapis.com/v1beta', max_retries: 2, is_active: true, total_keys: 1, active_keys: 1, created_at: '2024-01-15T00:00:00Z', updated_at: new Date().toISOString(), ...getMockEndpointExtras('gemini:generate_content') }
|
||||
]
|
||||
|
||||
// Mock 能力定义
|
||||
@@ -1737,9 +1734,10 @@ function generateMockEndpointsForProvider(providerId: string) {
|
||||
return provider.api_formats.map((format, index) => {
|
||||
const normalizedFormat = normalizeApiFormat(format)
|
||||
const healthDetail = provider.endpoint_health_details.find(h => h.api_format === format)
|
||||
const baseUrl = normalizedFormat.includes('claude') ? 'https://api.anthropic.com' :
|
||||
normalizedFormat.includes('openai') ? 'https://api.openai.com' :
|
||||
'https://generativelanguage.googleapis.com'
|
||||
const baseUrl = normalizedFormat.includes('claude') ? 'https://api.anthropic.com/v1' :
|
||||
normalizedFormat.includes('openai') ? 'https://api.openai.com/v1' :
|
||||
normalizedFormat.includes('jina') ? 'https://api.jina.ai/v1' :
|
||||
'https://generativelanguage.googleapis.com'
|
||||
return {
|
||||
id: `ep-${providerId}-${index + 1}`,
|
||||
provider_id: providerId,
|
||||
|
||||
Reference in New Issue
Block a user