feat: support runtime shared AI settings

This commit is contained in:
2026-06-22 10:13:04 +08:00
parent 278bc28416
commit 69ce717ec6
10 changed files with 830 additions and 47 deletions
+56 -22
View File
@@ -9,12 +9,17 @@ from app.core.config import settings
from app.core.response import success_response
from app.db import get_db_session
from app.core.security import verify_password
from app.clients.shared_ai_platform import SharedAIPlatformClient, get_shared_ai_platform_client
from app.clients.shared_ai_platform import (
SharedAIPlatformClient,
get_shared_ai_platform_client,
resolve_shared_ai_platform_runtime_config,
)
from app.core.roles import ROLE_ADMIN
from app.models.user import User
from app.services.audit_log_service import AuditLogService
from app.services.auth_service import AuthService, DEFAULT_ADMIN_PASSWORD, DEFAULT_ADMIN_USERNAME
from app.services.governance_service import GovernanceService
from app.services.model_setting_service import ModelSettingService
from app.services.template_service import TemplateService
router = APIRouter(prefix="/system", tags=["system"])
@@ -638,7 +643,28 @@ def _check_object_storage_config() -> Dict[str, Any]:
)
def _check_shared_ai_config() -> Dict[str, Any]:
def _has_model_settings_shared_ai_config(db: Optional[Session]) -> bool:
if db is None:
return False
try:
return ModelSettingService(db).has_any_shared_ai_platform_runtime_config()
except Exception:
return False
def _shared_ai_runtime_configured(db: Optional[Session]) -> bool:
base_url = _clean_config_value(settings.shared_ai_platform_base_url)
api_key = _clean_config_value(settings.shared_ai_platform_api_key)
env_configured = (
bool(base_url)
and bool(api_key)
and not _is_placeholder_config_value(base_url)
and not _is_placeholder_config_value(api_key)
)
return env_configured or _has_model_settings_shared_ai_config(db)
def _check_shared_ai_config(db: Optional[Session] = None) -> Dict[str, Any]:
base_url = _clean_config_value(settings.shared_ai_platform_base_url)
api_key = _clean_config_value(settings.shared_ai_platform_api_key)
legal_review_enabled = settings.shared_ai_platform_legal_review_enabled
@@ -646,7 +672,7 @@ def _check_shared_ai_config() -> Dict[str, Any]:
base_url_configured = bool(base_url) and not _is_placeholder_config_value(base_url)
api_key_configured = bool(api_key) and not _is_placeholder_config_value(api_key)
if base_url_configured and api_key_configured:
if (base_url_configured and api_key_configured) or _has_model_settings_shared_ai_config(db):
return _build_check(
"shared_ai_config",
"共享 AI 配置",
@@ -898,19 +924,23 @@ def get_governance_service(db: Session = Depends(get_db_session)) -> GovernanceS
return GovernanceService(db)
def _build_shared_ai_status() -> Dict[str, Any]:
remote_configured = bool(
settings.shared_ai_platform_base_url.strip() and settings.shared_ai_platform_api_key.strip()
)
def _build_shared_ai_status(db: Optional[Session] = None, current_user: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
tenant_id = str((current_user or {}).get("tenant_id") or "").strip() or None
runtime_config = resolve_shared_ai_platform_runtime_config(db, tenant_id) if db is not None else None
remote_configured = runtime_config is not None
mode = "remote-preferred" if remote_configured else "configuration-required"
return {
"mode": mode,
"remote_configured": remote_configured,
"base_url": settings.shared_ai_platform_base_url.strip() or None,
"api_prefix": settings.shared_ai_platform_api_prefix,
"app_id": settings.shared_ai_platform_app_id,
"timeout_seconds": settings.shared_ai_platform_timeout_seconds,
"base_url": runtime_config.base_url if runtime_config else (settings.shared_ai_platform_base_url.strip() or None),
"api_prefix": runtime_config.api_prefix if runtime_config else settings.shared_ai_platform_api_prefix,
"app_id": runtime_config.app_id if runtime_config else settings.shared_ai_platform_app_id,
"timeout_seconds": runtime_config.timeout_seconds if runtime_config else settings.shared_ai_platform_timeout_seconds,
"config_source": runtime_config.source if runtime_config else None,
"provider": runtime_config.provider if runtime_config else None,
"model": runtime_config.model if runtime_config else None,
"review_model": runtime_config.review_model if runtime_config else None,
"transport": {
"sdk": "local-shared-ai-sdk" if remote_configured else "disabled",
"fallback": "disabled",
@@ -924,8 +954,14 @@ def _build_shared_ai_status() -> Dict[str, Any]:
"knowledge_bases": remote_configured,
"knowledge_documents": remote_configured,
"knowledge_retry": remote_configured,
"legal_review_domain_pack": remote_configured and settings.shared_ai_platform_legal_review_enabled,
"local_legal_review_fallback": remote_configured and settings.shared_ai_platform_legal_review_fallback_to_local,
"legal_review_domain_pack": remote_configured
and (runtime_config.legal_review_enabled if runtime_config else settings.shared_ai_platform_legal_review_enabled),
"local_legal_review_fallback": remote_configured
and (
runtime_config.legal_review_fallback_to_local
if runtime_config
else settings.shared_ai_platform_legal_review_fallback_to_local
),
},
}
@@ -1047,7 +1083,7 @@ def get_system_readiness(db: Session = Depends(get_db_session)):
_check_database(db),
_check_runtime_config(),
_check_object_storage_config(),
_check_shared_ai_config(),
_check_shared_ai_config(db),
_build_check("auth_seed", "默认管理员初始化", "ok", "默认管理员初始化入口可用", required=False),
default_admin_password_check,
]
@@ -1068,12 +1104,7 @@ def get_system_readiness(db: Session = Depends(get_db_session)):
"app_env": settings.app_env,
"api_prefix": settings.api_v1_prefix,
"file_storage_backend": (settings.file_storage_backend or "minio").strip().lower(),
"shared_ai_remote_configured": bool(
_clean_config_value(settings.shared_ai_platform_base_url)
and _clean_config_value(settings.shared_ai_platform_api_key)
and not _is_placeholder_config_value(settings.shared_ai_platform_base_url)
and not _is_placeholder_config_value(settings.shared_ai_platform_api_key)
),
"shared_ai_remote_configured": _shared_ai_runtime_configured(db),
"shared_ai_legal_review_enabled": settings.shared_ai_platform_legal_review_enabled,
},
"checks": checks,
@@ -1093,8 +1124,11 @@ def get_system_readiness(db: Session = Depends(get_db_session)):
@router.get("/shared-ai")
def get_shared_ai_status(_current_user: dict = Depends(require_current_user)):
return success_response(_build_shared_ai_status())
def get_shared_ai_status(
current_user: dict = Depends(require_current_user),
db: Session = Depends(get_db_session),
):
return success_response(_build_shared_ai_status(db, current_user))
@router.get("/p0-mainline")
+288 -15
View File
@@ -1,5 +1,6 @@
import inspect
import json
from dataclasses import dataclass
from typing import Any, Dict, Iterable, Mapping, Optional, Protocol
from fastapi import Depends, HTTPException
@@ -10,6 +11,7 @@ from app.db import get_db_session
from app.repositories.conversation_repository import ConversationRepository
from app.repositories.message_repository import MessageRepository
from app.services.contract_content_normalizer import normalize_contract_content
from app.services.model_setting_service import ModelSettingService
from shared_ai_platform_sdk import RequestContext as SharedAIRequestContext
from shared_ai_platform_sdk import SharedAIPlatformClient as SharedAIPlatformSdkClient
from shared_ai_platform_sdk import SharedAIPlatformError
@@ -20,6 +22,33 @@ REMOTE_CHAT_TEMPLATE_KEYS = {
"site-chat.customer-service",
}
PLACEHOLDER_SHARED_AI_CONFIG_VALUES = {
"change-me",
"change_me",
"changeme",
"replace-me",
"replace_me",
"todo",
"tbd",
"change_me_shared_ai_api_key",
}
@dataclass(frozen=True)
class SharedAIPlatformRuntimeConfig:
base_url: str
api_key: str
app_id: str
api_prefix: str
timeout_seconds: float
source: str
provider: Optional[str] = None
model: Optional[str] = None
review_model: Optional[str] = None
legal_review_enabled: bool = True
legal_review_fallback_to_local: bool = False
document_extraction_enabled: bool = True
class SharedAIPlatformClient(Protocol):
def stream_answer(
@@ -92,18 +121,29 @@ class SharedAIPlatformClient(Protocol):
class RemoteSharedAIPlatformClient:
def __init__(self, base_url: str, app_id: str, api_key: str, timeout_seconds: float, db: Optional[Session] = None):
def __init__(
self,
base_url: str,
app_id: str,
api_key: str,
timeout_seconds: float,
db: Optional[Session] = None,
api_prefix: Optional[str] = None,
runtime_config: Optional[SharedAIPlatformRuntimeConfig] = None,
):
self.base_url = base_url.rstrip("/")
self.app_id = app_id
self.api_key = api_key
self.timeout_seconds = timeout_seconds
self.db = db
self.runtime_config = runtime_config
self.api_prefix = (api_prefix or settings.shared_ai_platform_api_prefix).strip() or "/api/v0.2"
self.sdk = SharedAIPlatformSdkClient(
base_url=self.base_url,
api_key=self.api_key,
app_id=self.app_id,
timeout_seconds=self.timeout_seconds,
api_prefix=settings.shared_ai_platform_api_prefix,
api_prefix=self.api_prefix,
)
def _context(self, current_user: Dict[str, Any]) -> SharedAIRequestContext:
@@ -122,7 +162,9 @@ class RemoteSharedAIPlatformClient:
template_key: Optional[str] = None,
) -> Iterable[str]:
review_context_payload = self._normalize_review_context(review_context)
review_context_payload = self._merge_runtime_model_context(review_context_payload)
remote_template_key = self._resolve_remote_chat_template_key(template_key)
provider = self._resolve_provider(review_context_payload)
def _event_stream():
attempt_template_keys = [remote_template_key]
@@ -139,6 +181,7 @@ class RemoteSharedAIPlatformClient:
self._context(current_user),
message=message,
chat_context=review_context_payload or {},
provider=provider,
conversation_id=conversation_id,
template_key=active_template_key,
):
@@ -247,13 +290,14 @@ class RemoteSharedAIPlatformClient:
return self._fallback_get_conversation_detail(current_user, conversation_id, exc)
def run_legal_review(self, current_user: Dict[str, Any], payload: Dict[str, Any]) -> dict:
provider = payload.get("provider") or (self.runtime_config.provider if self.runtime_config else None)
return self.sdk.run_legal_review(
self._context(current_user),
title=payload.get("title") or "文本输入合同",
text=payload.get("text") or "",
contract_type=payload.get("contract_type"),
review_as=payload.get("review_as") or "party_a",
provider=payload.get("provider"),
provider=provider,
)
def extract_document_text(self, current_user: Dict[str, Any], *, file_name: str, mime_type: Optional[str], content: bytes) -> dict:
@@ -561,6 +605,37 @@ class RemoteSharedAIPlatformClient:
return review_context.model_dump(mode="json", by_alias=True, exclude_none=True)
return review_context
def _merge_runtime_model_context(self, review_context: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
if self.runtime_config is None:
return review_context
enriched = dict(review_context or {})
runtime_payload = dict(enriched.get("modelRuntime") or enriched.get("model_runtime") or {})
runtime_payload.setdefault("source", self.runtime_config.source)
if self.runtime_config.provider:
runtime_payload.setdefault("provider", self.runtime_config.provider)
if self.runtime_config.model:
runtime_payload.setdefault("model", self.runtime_config.model)
if self.runtime_config.review_model:
runtime_payload.setdefault("review_model", self.runtime_config.review_model)
enriched["modelRuntime"] = runtime_payload
enriched["model_runtime"] = runtime_payload
metadata = dict(enriched.get("metadata") or {})
metadata.setdefault("modelRuntime", runtime_payload)
enriched["metadata"] = metadata
return enriched
def _resolve_provider(self, review_context: Optional[Dict[str, Any]]) -> Optional[str]:
if isinstance(review_context, Mapping):
runtime_payload = review_context.get("modelRuntime") or review_context.get("model_runtime")
if isinstance(runtime_payload, Mapping) and runtime_payload.get("provider"):
return str(runtime_payload.get("provider"))
metadata = review_context.get("metadata")
if isinstance(metadata, Mapping):
metadata_runtime = metadata.get("modelRuntime") or metadata.get("model_runtime")
if isinstance(metadata_runtime, Mapping) and metadata_runtime.get("provider"):
return str(metadata_runtime.get("provider"))
return self.runtime_config.provider if self.runtime_config else None
def _fallback_list_conversations(self, current_user: Dict[str, Any], page: int, page_size: int, exc: Exception) -> dict:
if self.db is None:
raise HTTPException(status_code=503, detail=self._build_history_error_message(exc)) from exc
@@ -811,22 +886,220 @@ def get_shared_ai_platform_client(db: Session = Depends(get_db_session)) -> Shar
return build_shared_ai_platform_client(db)
def resolve_shared_ai_platform_runtime_config(
db: Session,
tenant_id: Optional[str] = None,
) -> Optional[SharedAIPlatformRuntimeConfig]:
if tenant_id:
try:
db_config = ModelSettingService(db).get_shared_ai_platform_runtime_config(str(tenant_id))
except Exception:
db_config = {}
if db_config.get("configured"):
return SharedAIPlatformRuntimeConfig(
base_url=str(db_config.get("base_url") or "").strip(),
api_key=str(db_config.get("api_key") or "").strip(),
app_id=str(db_config.get("app_id") or settings.shared_ai_platform_app_id),
api_prefix=str(db_config.get("api_prefix") or settings.shared_ai_platform_api_prefix),
timeout_seconds=float(db_config.get("timeout_seconds") or settings.shared_ai_platform_timeout_seconds),
source=str(db_config.get("source") or "model_settings"),
provider=str(db_config.get("provider") or "") or None,
model=str(db_config.get("model") or "") or None,
review_model=str(db_config.get("review_model") or "") or None,
legal_review_enabled=bool(db_config.get("legal_review_enabled")),
legal_review_fallback_to_local=bool(db_config.get("legal_review_fallback_to_local")),
document_extraction_enabled=bool(db_config.get("document_extraction_enabled")),
)
def build_shared_ai_platform_client(db: Session) -> SharedAIPlatformClient:
base_url = settings.shared_ai_platform_base_url.strip()
api_key = settings.shared_ai_platform_api_key.strip()
missing_parts = []
if not base_url:
missing_parts.append("SHARED_AI_PLATFORM_BASE_URL")
if not api_key:
missing_parts.append("SHARED_AI_PLATFORM_API_KEY")
if missing_parts:
return UnavailableSharedAIPlatformClient(f"缺少 {', '.join(missing_parts)}", db=db)
return RemoteSharedAIPlatformClient(
if not _is_runtime_config_value_ready(base_url) or not _is_runtime_config_value_ready(api_key):
return None
return SharedAIPlatformRuntimeConfig(
base_url=base_url,
app_id=settings.shared_ai_platform_app_id,
api_key=api_key,
app_id=settings.shared_ai_platform_app_id,
api_prefix=settings.shared_ai_platform_api_prefix,
timeout_seconds=settings.shared_ai_platform_timeout_seconds,
db=db,
source="env",
legal_review_enabled=settings.shared_ai_platform_legal_review_enabled,
legal_review_fallback_to_local=settings.shared_ai_platform_legal_review_fallback_to_local,
document_extraction_enabled=settings.shared_ai_platform_document_extraction_enabled,
)
def _is_runtime_config_value_ready(value: str) -> bool:
normalized = str(value or "").strip()
return bool(normalized) and normalized.lower() not in PLACEHOLDER_SHARED_AI_CONFIG_VALUES
class TenantAwareSharedAIPlatformClient:
def __init__(self, db: Session):
self.db = db
def _client_for_user(self, current_user: Dict[str, Any]) -> SharedAIPlatformClient:
tenant_id = str(current_user.get("tenant_id") or "").strip()
runtime_config = resolve_shared_ai_platform_runtime_config(self.db, tenant_id)
if runtime_config is None:
return _build_unavailable_shared_ai_platform_client(self.db)
return _build_remote_shared_ai_platform_client(self.db, runtime_config)
def stream_answer(
self,
current_user: Dict[str, Any],
conversation_id: Optional[str],
message: str,
review_context: Optional[Any] = None,
template_key: Optional[str] = None,
) -> Iterable[str]:
return self._client_for_user(current_user).stream_answer(
current_user,
conversation_id,
message,
review_context,
template_key=template_key,
)
def list_knowledge_bases(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict:
return self._client_for_user(current_user).list_knowledge_bases(current_user, page, page_size)
def list_conversations(self, current_user: Dict[str, Any], page: int, page_size: int) -> dict:
return self._client_for_user(current_user).list_conversations(current_user, page, page_size)
def get_conversation_detail(self, current_user: Dict[str, Any], conversation_id: str) -> dict:
return self._client_for_user(current_user).get_conversation_detail(current_user, conversation_id)
def run_legal_review(self, current_user: Dict[str, Any], payload: Dict[str, Any]) -> dict:
return self._client_for_user(current_user).run_legal_review(current_user, payload)
def extract_document_text(
self,
current_user: Dict[str, Any],
*,
file_name: str,
mime_type: Optional[str],
content: bytes,
) -> dict:
return self._client_for_user(current_user).extract_document_text(
current_user,
file_name=file_name,
mime_type=mime_type,
content=content,
)
def classify_pdf_source(self, current_user: Dict[str, Any], *, content: bytes) -> dict:
return self._client_for_user(current_user).classify_pdf_source(current_user, content=content)
def create_knowledge_base(self, current_user: Dict[str, Any], name: str, description: Optional[str]) -> dict:
return self._client_for_user(current_user).create_knowledge_base(current_user, name, description)
def list_knowledge_base_versions(self, current_user: Dict[str, Any], knowledge_base_id: str) -> list[dict]:
return self._client_for_user(current_user).list_knowledge_base_versions(current_user, knowledge_base_id)
def publish_knowledge_base(self, current_user: Dict[str, Any], knowledge_base_id: str) -> dict:
return self._client_for_user(current_user).publish_knowledge_base(current_user, knowledge_base_id)
def unpublish_knowledge_base(self, current_user: Dict[str, Any], knowledge_base_id: str) -> dict:
return self._client_for_user(current_user).unpublish_knowledge_base(current_user, knowledge_base_id)
def rollback_knowledge_base(
self,
current_user: Dict[str, Any],
knowledge_base_id: str,
target_version_label: str,
change_summary: Optional[str] = None,
) -> dict:
return self._client_for_user(current_user).rollback_knowledge_base(
current_user,
knowledge_base_id,
target_version_label,
change_summary,
)
def list_documents(self, current_user: Dict[str, Any], knowledge_base_id: str, page: int, page_size: int) -> dict:
return self._client_for_user(current_user).list_documents(current_user, knowledge_base_id, page, page_size)
def upload_document(
self,
current_user: Dict[str, Any],
knowledge_base_id: str,
file_name: str,
mime_type: Optional[str],
content: bytes,
) -> dict:
return self._client_for_user(current_user).upload_document(
current_user,
knowledge_base_id,
file_name,
mime_type,
content,
)
def retry_document(
self,
current_user: Dict[str, Any],
knowledge_base_id: str,
document_id: str,
file_name: Optional[str] = None,
mime_type: Optional[str] = None,
content: Optional[bytes] = None,
) -> dict:
return self._client_for_user(current_user).retry_document(
current_user,
knowledge_base_id,
document_id,
file_name,
mime_type,
content,
)
def enhance_contract_generation(
self,
current_user: Dict[str, Any],
*,
prompt_runtime: Optional[Dict[str, Any]],
prompt: str,
draft_content: str,
metadata: Optional[Dict[str, Any]] = None,
) -> dict:
return self._client_for_user(current_user).enhance_contract_generation(
current_user,
prompt_runtime=prompt_runtime,
prompt=prompt,
draft_content=draft_content,
metadata=metadata,
)
def _build_remote_shared_ai_platform_client(
db: Session,
runtime_config: SharedAIPlatformRuntimeConfig,
) -> RemoteSharedAIPlatformClient:
return RemoteSharedAIPlatformClient(
base_url=runtime_config.base_url,
app_id=runtime_config.app_id,
api_key=runtime_config.api_key,
timeout_seconds=runtime_config.timeout_seconds,
db=db,
api_prefix=runtime_config.api_prefix,
runtime_config=runtime_config,
)
def _build_unavailable_shared_ai_platform_client(db: Session) -> UnavailableSharedAIPlatformClient:
missing_parts = []
if not settings.shared_ai_platform_base_url.strip():
missing_parts.append("SHARED_AI_PLATFORM_BASE_URL")
if not settings.shared_ai_platform_api_key.strip():
missing_parts.append("SHARED_AI_PLATFORM_API_KEY")
if not missing_parts:
missing_parts.append("model_settings.shared_ai_platform")
return UnavailableSharedAIPlatformClient(f"缺少 {', '.join(missing_parts)}", db=db)
def build_shared_ai_platform_client(db: Session) -> SharedAIPlatformClient:
runtime_config = resolve_shared_ai_platform_runtime_config(db)
if runtime_config is not None and runtime_config.source == "env":
return _build_remote_shared_ai_platform_client(db, runtime_config)
return TenantAwareSharedAIPlatformClient(db)
+247 -4
View File
@@ -20,6 +20,23 @@ SUPPORTED_PROVIDER_TYPES = {"openai", "anthropic"}
SUPPORTED_CONFIG_FORMATS = {"openai_toml", "anthropic_env_json"}
OPENAI_SECRET_FIELDS = {"api_key", "auth_token", "OPENAI_API_KEY", "SHARED_AI_OPENAI_API_KEY"}
ANTHROPIC_SECRET_FIELDS = {"ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"}
SHARED_AI_PLATFORM_SECRET_FIELDS = {
"SHARED_AI_PLATFORM_API_KEY",
"shared_ai_platform_api_key",
"api_key_encrypted",
}
SHARED_AI_PLATFORM_CONFIG_KEYS = {
"base_url",
"api_key",
"api_key_encrypted",
"api_prefix",
"app_id",
"timeout_seconds",
"provider",
"legal_review_enabled",
"legal_review_fallback_to_local",
"document_extraction_enabled",
}
class ModelSettingService:
@@ -59,6 +76,7 @@ class ModelSettingService:
elif not token and existing and existing.auth_token_encrypted:
token = self._decrypt_secret(existing.auth_token_encrypted)
encrypted_token = self._encrypt_secret(token) if token else None
config_json = self._prepare_config_json_for_storage(parsed["config_json"], existing)
now = datetime.now(timezone.utc)
if existing is None:
@@ -87,7 +105,7 @@ class ModelSettingService:
setting.reasoning_effort = parsed.get("reasoning_effort")
setting.network_access = parsed.get("network_access")
setting.auth_token_encrypted = encrypted_token
setting.config_json = self._without_secrets(parsed["config_json"])
setting.config_json = config_json
setting.raw_config = self._mask_raw_config(normalized_format, normalized_raw)
setting.updated_by = current_user.get("id")
setting.updated_at = now
@@ -106,15 +124,18 @@ class ModelSettingService:
"provider_type": None,
"env": {},
"config": None,
"shared_ai_platform": self._empty_shared_ai_platform_summary(),
}
token = self._decrypt_secret(setting.auth_token_encrypted)
if setting.provider_type == "anthropic":
env = self._build_anthropic_env(setting, token)
env.update(self._build_shared_ai_platform_env(setting))
return {
"configured": bool(setting.enabled and env.get("ANTHROPIC_BASE_URL") and env.get("ANTHROPIC_AUTH_TOKEN")),
"provider_type": "anthropic",
"env": env,
"config": None,
"shared_ai_platform": self.get_shared_ai_platform_runtime_config(setting.tenant_id),
}
env = self._build_openai_shared_ai_env(setting, token)
return {
@@ -122,8 +143,53 @@ class ModelSettingService:
"provider_type": "openai",
"env": env,
"config": self._build_openai_codex_config(setting, token),
"shared_ai_platform": self.get_shared_ai_platform_runtime_config(setting.tenant_id),
}
def get_shared_ai_platform_runtime_config(self, tenant_id: str) -> dict:
setting = self._get_active_setting(tenant_id)
if setting is None or not setting.enabled:
return self._empty_shared_ai_platform_summary()
config = self._shared_ai_platform_config_from_setting(setting, include_secret=True)
configured = bool(config.get("base_url") and config.get("api_key"))
return {
"configured": configured,
"source": "model_settings" if configured else None,
"base_url": config.get("base_url"),
"api_prefix": config.get("api_prefix") or settings.shared_ai_platform_api_prefix,
"app_id": config.get("app_id") or settings.shared_ai_platform_app_id,
"api_key": config.get("api_key") or "",
"has_api_key": bool(config.get("api_key")),
"api_key_preview": self._preview_secret(str(config.get("api_key") or "")),
"timeout_seconds": self._coerce_float(
config.get("timeout_seconds"),
settings.shared_ai_platform_timeout_seconds,
),
"provider": config.get("provider") or setting.provider_type,
"model_provider": setting.model_provider,
"model": setting.model,
"review_model": setting.review_model or setting.model,
"legal_review_enabled": self._coerce_bool(
config.get("legal_review_enabled"),
settings.shared_ai_platform_legal_review_enabled,
),
"legal_review_fallback_to_local": self._coerce_bool(
config.get("legal_review_fallback_to_local"),
settings.shared_ai_platform_legal_review_fallback_to_local,
),
"document_extraction_enabled": self._coerce_bool(
config.get("document_extraction_enabled"),
settings.shared_ai_platform_document_extraction_enabled,
),
}
def has_any_shared_ai_platform_runtime_config(self) -> bool:
for setting in self.db.query(ModelSetting).filter(ModelSetting.enabled.is_(True)).all():
config = self._shared_ai_platform_config_from_setting(setting, include_secret=True)
if config.get("base_url") and config.get("api_key"):
return True
return False
def _get_active_setting(self, tenant_id: str) -> Optional[ModelSetting]:
return (
self.db.query(ModelSetting)
@@ -179,6 +245,10 @@ class ModelSettingService:
provider_name: dict(provider_section),
},
"features": data.get("features") if isinstance(data.get("features"), Mapping) else {},
"shared_ai_platform": self._parse_shared_ai_platform_config(
data.get("shared_ai_platform") if isinstance(data.get("shared_ai_platform"), Mapping) else {},
fallback=data,
),
}
return {
"provider_type": "openai",
@@ -205,7 +275,13 @@ class ModelSettingService:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="env must be an object")
base_url = self._required_str(env.get("ANTHROPIC_BASE_URL"), "ANTHROPIC_BASE_URL")
token = self._optional_str(env.get("ANTHROPIC_AUTH_TOKEN") or env.get("ANTHROPIC_API_KEY"))
config_json = {"env": {key: str(value) for key, value in env.items() if value is not None}}
config_json = {
"env": {key: str(value) for key, value in env.items() if value is not None},
"shared_ai_platform": self._parse_shared_ai_platform_config(
payload.get("shared_ai_platform") if isinstance(payload.get("shared_ai_platform"), Mapping) else {},
fallback=env,
),
}
return {
"provider_type": "anthropic",
"model_provider": "Anthropic",
@@ -296,6 +372,7 @@ class ModelSettingService:
"has_auth_token": bool(token),
"auth_token_preview": self._preview_secret(token),
"config_json": self._without_secrets(setting.config_json or {}),
"shared_ai_platform": self._shared_ai_platform_public_summary(setting),
"created_at": setting.created_at.isoformat() if setting.created_at else None,
"updated_at": setting.updated_at.isoformat() if setting.updated_at else None,
}
@@ -322,6 +399,7 @@ class ModelSettingService:
"has_auth_token": False,
"auth_token_preview": None,
"config_json": {},
"shared_ai_platform": ModelSettingService._empty_shared_ai_platform_summary(),
"raw_config": "",
"created_at": None,
"updated_at": None,
@@ -329,7 +407,7 @@ class ModelSettingService:
def _build_openai_shared_ai_env(self, setting: ModelSetting, token: str) -> dict[str, str]:
config_json = setting.config_json or {}
return {
env = {
"SHARED_AI_DEFAULT_PROVIDER": "openai_compatible",
"SHARED_AI_OPENAI_BASE_URL": setting.base_url or "",
"SHARED_AI_OPENAI_API_KEY": token,
@@ -338,6 +416,8 @@ class ModelSettingService:
"SHARED_AI_OPENAI_MAX_TOKENS": str(config_json.get("max_tokens") or "1200"),
"SHARED_AI_OPENAI_TIMEOUT_SECONDS": str(config_json.get("timeout_seconds") or settings.shared_ai_platform_timeout_seconds),
}
env.update(self._build_shared_ai_platform_env(setting))
return env
@staticmethod
def _build_anthropic_env(setting: ModelSetting, token: str) -> dict[str, str]:
@@ -346,6 +426,21 @@ class ModelSettingService:
env["ANTHROPIC_AUTH_TOKEN"] = token or env.get("ANTHROPIC_AUTH_TOKEN") or ""
return {str(key): str(value) for key, value in env.items() if value is not None}
def _build_shared_ai_platform_env(self, setting: ModelSetting) -> dict[str, str]:
config = self._shared_ai_platform_config_from_setting(setting, include_secret=True)
if not config:
return {}
env = {
"SHARED_AI_PLATFORM_BASE_URL": str(config.get("base_url") or ""),
"SHARED_AI_PLATFORM_API_KEY": str(config.get("api_key") or ""),
"SHARED_AI_PLATFORM_APP_ID": str(config.get("app_id") or settings.shared_ai_platform_app_id),
"SHARED_AI_PLATFORM_API_PREFIX": str(config.get("api_prefix") or settings.shared_ai_platform_api_prefix),
"SHARED_AI_PLATFORM_TIMEOUT_SECONDS": str(
self._coerce_float(config.get("timeout_seconds"), settings.shared_ai_platform_timeout_seconds)
),
}
return {key: value for key, value in env.items() if value}
@staticmethod
def _build_openai_codex_config(setting: ModelSetting, token: str) -> dict[str, Any]:
provider_name = setting.model_provider or "OpenAI"
@@ -389,7 +484,11 @@ class ModelSettingService:
if isinstance(value, Mapping):
masked: dict[str, Any] = {}
for key, item in value.items():
if str(key) in OPENAI_SECRET_FIELDS or str(key) in ANTHROPIC_SECRET_FIELDS:
if (
str(key) in OPENAI_SECRET_FIELDS
or str(key) in ANTHROPIC_SECRET_FIELDS
or str(key) in SHARED_AI_PLATFORM_SECRET_FIELDS
):
masked[str(key)] = "***" if item else ""
else:
masked[str(key)] = self._without_secrets(item)
@@ -436,3 +535,147 @@ class ModelSettingService:
if len(value) <= 8:
return "***"
return f"{value[:4]}...{value[-4:]}"
def _prepare_config_json_for_storage(self, parsed_config_json: Mapping[str, Any], existing: Optional[ModelSetting]) -> dict:
config_json = dict(parsed_config_json)
shared_config = dict(config_json.get("shared_ai_platform") or {})
shared_api_key = self._resolve_shared_ai_platform_api_key(shared_config, existing)
shared_config.pop("api_key", None)
if shared_api_key:
shared_config["api_key_encrypted"] = self._encrypt_secret(shared_api_key)
elif "api_key_encrypted" in shared_config:
shared_config.pop("api_key_encrypted", None)
if self._has_shared_ai_platform_config_values(shared_config):
config_json["shared_ai_platform"] = shared_config
else:
config_json.pop("shared_ai_platform", None)
return config_json
def _resolve_shared_ai_platform_api_key(self, shared_config: Mapping[str, Any], existing: Optional[ModelSetting]) -> str:
raw_key = str(shared_config.get("api_key") or "").strip()
if raw_key and raw_key not in {"***", MASKED_SECRET}:
return raw_key
if existing is None:
return ""
return self._shared_ai_platform_config_from_setting(existing, include_secret=True).get("api_key") or ""
def _parse_shared_ai_platform_config(
self,
value: Mapping[str, Any],
*,
fallback: Optional[Mapping[str, Any]] = None,
) -> dict[str, Any]:
fallback = fallback or {}
config = {
"base_url": self._optional_str(
value.get("base_url")
or value.get("SHARED_AI_PLATFORM_BASE_URL")
or fallback.get("SHARED_AI_PLATFORM_BASE_URL")
or fallback.get("shared_ai_platform_base_url")
),
"api_key": self._optional_str(
value.get("api_key")
or value.get("auth_token")
or value.get("SHARED_AI_PLATFORM_API_KEY")
or fallback.get("SHARED_AI_PLATFORM_API_KEY")
or fallback.get("shared_ai_platform_api_key")
),
"api_prefix": self._optional_str(
value.get("api_prefix")
or value.get("SHARED_AI_PLATFORM_API_PREFIX")
or fallback.get("SHARED_AI_PLATFORM_API_PREFIX")
or fallback.get("shared_ai_platform_api_prefix")
),
"app_id": self._optional_str(
value.get("app_id")
or value.get("SHARED_AI_PLATFORM_APP_ID")
or fallback.get("SHARED_AI_PLATFORM_APP_ID")
or fallback.get("shared_ai_platform_app_id")
),
"timeout_seconds": value.get("timeout_seconds")
or value.get("SHARED_AI_PLATFORM_TIMEOUT_SECONDS")
or fallback.get("SHARED_AI_PLATFORM_TIMEOUT_SECONDS")
or fallback.get("shared_ai_platform_timeout_seconds"),
"provider": self._optional_str(value.get("provider") or fallback.get("SHARED_AI_DEFAULT_PROVIDER")),
"legal_review_enabled": value.get("legal_review_enabled"),
"legal_review_fallback_to_local": value.get("legal_review_fallback_to_local"),
"document_extraction_enabled": value.get("document_extraction_enabled"),
}
return {key: item for key, item in config.items() if item not in (None, "")}
def _shared_ai_platform_config_from_setting(self, setting: ModelSetting, *, include_secret: bool = False) -> dict[str, Any]:
raw_config = (setting.config_json or {}).get("shared_ai_platform")
if not isinstance(raw_config, Mapping):
return {}
config = {key: raw_config.get(key) for key in SHARED_AI_PLATFORM_CONFIG_KEYS if raw_config.get(key) not in (None, "")}
if include_secret:
encrypted = str(raw_config.get("api_key_encrypted") or "")
if encrypted:
config["api_key"] = self._decrypt_secret(encrypted)
return config
def _shared_ai_platform_public_summary(self, setting: ModelSetting) -> dict[str, Any]:
config = self._shared_ai_platform_config_from_setting(setting, include_secret=True)
if not config:
return self._empty_shared_ai_platform_summary()
return {
"configured": bool(setting.enabled and config.get("base_url") and config.get("api_key")),
"source": "model_settings",
"base_url": config.get("base_url"),
"api_prefix": config.get("api_prefix") or settings.shared_ai_platform_api_prefix,
"app_id": config.get("app_id") or settings.shared_ai_platform_app_id,
"has_api_key": bool(config.get("api_key")),
"api_key_preview": self._preview_secret(str(config.get("api_key") or "")),
"timeout_seconds": self._coerce_float(config.get("timeout_seconds"), settings.shared_ai_platform_timeout_seconds),
"provider": config.get("provider") or setting.provider_type,
"legal_review_enabled": self._coerce_bool(config.get("legal_review_enabled"), settings.shared_ai_platform_legal_review_enabled),
"legal_review_fallback_to_local": self._coerce_bool(
config.get("legal_review_fallback_to_local"),
settings.shared_ai_platform_legal_review_fallback_to_local,
),
"document_extraction_enabled": self._coerce_bool(
config.get("document_extraction_enabled"),
settings.shared_ai_platform_document_extraction_enabled,
),
}
@staticmethod
def _empty_shared_ai_platform_summary() -> dict[str, Any]:
return {
"configured": False,
"source": None,
"base_url": None,
"api_prefix": None,
"app_id": None,
"has_api_key": False,
"api_key_preview": None,
"timeout_seconds": None,
"provider": None,
"legal_review_enabled": None,
"legal_review_fallback_to_local": None,
"document_extraction_enabled": None,
}
@staticmethod
def _has_shared_ai_platform_config_values(value: Mapping[str, Any]) -> bool:
return any(value.get(key) not in (None, "") for key in SHARED_AI_PLATFORM_CONFIG_KEYS)
@staticmethod
def _coerce_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return float(default)
@staticmethod
def _coerce_bool(value: Any, default: bool) -> bool:
if value is None:
return bool(default)
if isinstance(value, bool):
return value
normalized = str(value).strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return bool(default)
+1 -1
View File
@@ -187,7 +187,7 @@ class PromptAssetService:
self.run_link_repository = PromptAssetRunLinkRepository(db)
self.evaluation_run_repository = PromptAssetEvaluationRunRepository(db)
self.quality_eval_service = QualityEvalService(db) if db is not None else None
self.golden_sample_evaluator = PromptGoldenSampleEvaluator()
self.golden_sample_evaluator = PromptGoldenSampleEvaluator(db=db)
def get_first_priority_blueprint(self, current_user: Dict[str, Any]) -> dict[str, Any]:
self._require_admin(current_user)
@@ -7,7 +7,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Mapping, Optional
from app.clients.shared_ai_platform import RemoteSharedAIPlatformClient
from app.clients.shared_ai_platform import RemoteSharedAIPlatformClient, build_shared_ai_platform_client
from app.core.config import settings
from app.models.prompt_asset import PromptAsset
from app.models.prompt_asset_version import PromptAssetVersion
@@ -59,6 +59,7 @@ class PromptGoldenSampleEvaluator:
*,
repo_root: Optional[Path | str] = None,
llm_client: Optional[Any] = None,
db: Optional[Any] = None,
evaluation_mode: Optional[str] = None,
include_raw_model_response: Optional[bool] = None,
):
@@ -66,6 +67,7 @@ class PromptGoldenSampleEvaluator:
manifest_path = resolved_root / "project-docs" / "phase15-prompt-golden-samples" / "prompt-golden-suite-manifest.json"
self.repo_root = resolved_root if manifest_path.exists() else Path(__file__).resolve().parents[3]
self.llm_client = llm_client
self.db = db
self.evaluation_mode = self._normalize_mode(evaluation_mode or settings.prompt_golden_eval_mode)
self.include_raw_model_response = (
settings.prompt_golden_eval_include_raw_model_response
@@ -416,6 +418,8 @@ class PromptGoldenSampleEvaluator:
return None
if self.llm_client is not None:
return self.llm_client
if self.db is not None:
return build_shared_ai_platform_client(self.db)
if not settings.shared_ai_platform_base_url.strip() or not settings.shared_ai_platform_api_key.strip():
return None
return RemoteSharedAIPlatformClient(
+50 -3
View File
@@ -1,3 +1,4 @@
import json
import unittest
from fastapi.testclient import TestClient
@@ -25,6 +26,17 @@ base_url = "https://sub2api.example.test"
wire_api = "responses"
requires_openai_auth = true
[shared_ai_platform]
base_url = "http://shared-ai.example.test"
api_key = "shared-platform-key"
api_prefix = "/api/v0.2"
app_id = "ai-contract-platform-test"
timeout_seconds = 45
provider = "openai"
legal_review_enabled = true
legal_review_fallback_to_local = true
document_extraction_enabled = true
[features]
goals = true
'''
@@ -36,7 +48,18 @@ ANTHROPIC_JSON = {
"ANTHROPIC_AUTH_TOKEN": "sk-test-anthropic",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0",
}
},
"shared_ai_platform": {
"base_url": "http://shared-ai-anthropic.example.test",
"api_key": "shared-platform-anthropic-key",
"api_prefix": "/api/v0.2",
"app_id": "ai-contract-platform-test",
"timeout_seconds": 50,
"provider": "anthropic",
"legal_review_enabled": True,
"legal_review_fallback_to_local": False,
"document_extraction_enabled": True,
},
}
@@ -102,7 +125,15 @@ class ModelSettingsApiTestCase(unittest.TestCase):
self.assertEqual(body["wire_api"], "responses")
self.assertEqual(body["reasoning_effort"], "xhigh")
self.assertTrue(body["has_auth_token"])
self.assertTrue(body["shared_ai_platform"]["configured"])
self.assertEqual(body["shared_ai_platform"]["base_url"], "http://shared-ai.example.test")
self.assertEqual(body["shared_ai_platform"]["api_prefix"], "/api/v0.2")
self.assertEqual(body["shared_ai_platform"]["app_id"], "ai-contract-platform-test")
self.assertEqual(body["shared_ai_platform"]["provider"], "openai")
self.assertTrue(body["shared_ai_platform"]["legal_review_fallback_to_local"])
self.assertNotIn("shared-platform-key", json.dumps(body["config_json"], ensure_ascii=False))
self.assertNotIn("sk-test-openai", body["raw_config"])
self.assertNotIn("shared-platform-key", body["raw_config"])
export_response = self.client.get("/api/v1/model-settings/runtime-export")
@@ -112,13 +143,22 @@ class ModelSettingsApiTestCase(unittest.TestCase):
self.assertEqual(exported["env"]["SHARED_AI_OPENAI_BASE_URL"], "https://sub2api.example.test")
self.assertEqual(exported["env"]["SHARED_AI_OPENAI_API_KEY"], "sk-test-openai")
self.assertEqual(exported["env"]["SHARED_AI_OPENAI_MODEL"], "gpt-5.5")
self.assertEqual(exported["env"]["SHARED_AI_PLATFORM_BASE_URL"], "http://shared-ai.example.test")
self.assertEqual(exported["env"]["SHARED_AI_PLATFORM_API_KEY"], "shared-platform-key")
self.assertEqual(exported["env"]["SHARED_AI_PLATFORM_APP_ID"], "ai-contract-platform-test")
self.assertEqual(exported["env"]["SHARED_AI_PLATFORM_API_PREFIX"], "/api/v0.2")
self.assertEqual(exported["shared_ai_platform"]["api_key"], "shared-platform-key")
self.assertEqual(exported["config"]["model_providers"]["OpenAI"]["wire_api"], "responses")
updated_toml = OPENAI_TOML.replace("gpt-5.5", "gpt-5.6").replace(
'api_key = "shared-platform-key"',
'api_key = ""',
)
update_response = self.client.put(
"/api/v1/model-settings",
json={
"config_format": "openai_toml",
"raw_config": OPENAI_TOML.replace("gpt-5.5", "gpt-5.6"),
"raw_config": updated_toml,
"display_name": "Primary OpenAI compatible",
"auth_token": "",
},
@@ -129,6 +169,7 @@ class ModelSettingsApiTestCase(unittest.TestCase):
preserved_exported = preserved_export_response.json()["data"]
self.assertEqual(preserved_exported["env"]["SHARED_AI_OPENAI_API_KEY"], "sk-test-openai")
self.assertEqual(preserved_exported["env"]["SHARED_AI_OPENAI_MODEL"], "gpt-5.6")
self.assertEqual(preserved_exported["env"]["SHARED_AI_PLATFORM_API_KEY"], "shared-platform-key")
def test_admin_can_save_anthropic_env_json(self):
self._as_admin()
@@ -137,7 +178,7 @@ class ModelSettingsApiTestCase(unittest.TestCase):
"/api/v1/model-settings",
json={
"config_format": "anthropic_env_json",
"raw_config": __import__("json").dumps(ANTHROPIC_JSON),
"raw_config": json.dumps(ANTHROPIC_JSON),
"display_name": "Claude compatible",
},
)
@@ -147,8 +188,13 @@ class ModelSettingsApiTestCase(unittest.TestCase):
self.assertEqual(body["provider_type"], "anthropic")
self.assertEqual(body["base_url"], "https://sub2api.example.test")
self.assertTrue(body["has_auth_token"])
self.assertTrue(body["shared_ai_platform"]["configured"])
self.assertEqual(body["shared_ai_platform"]["base_url"], "http://shared-ai-anthropic.example.test")
self.assertEqual(body["shared_ai_platform"]["provider"], "anthropic")
self.assertEqual(body["config_json"]["env"]["ANTHROPIC_AUTH_TOKEN"], "***")
self.assertEqual(body["config_json"]["shared_ai_platform"]["api_key_encrypted"], "***")
self.assertNotIn("sk-test-anthropic", body["raw_config"])
self.assertNotIn("shared-platform-anthropic-key", body["raw_config"])
export_response = self.client.get("/api/v1/model-settings/runtime-export")
@@ -157,6 +203,7 @@ class ModelSettingsApiTestCase(unittest.TestCase):
self.assertEqual(exported["provider_type"], "anthropic")
self.assertEqual(exported["env"]["ANTHROPIC_BASE_URL"], "https://sub2api.example.test")
self.assertEqual(exported["env"]["ANTHROPIC_AUTH_TOKEN"], "sk-test-anthropic")
self.assertEqual(exported["env"]["SHARED_AI_PLATFORM_API_KEY"], "shared-platform-anthropic-key")
self.assertEqual(exported["env"]["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"], "1")
def test_member_cannot_access_model_settings(self):
+108 -1
View File
@@ -6,11 +6,17 @@ import httpx
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.clients.shared_ai_platform import RemoteSharedAIPlatformClient, build_shared_ai_platform_client
from app.clients.shared_ai_platform import (
RemoteSharedAIPlatformClient,
SharedAIPlatformRuntimeConfig,
build_shared_ai_platform_client,
resolve_shared_ai_platform_runtime_config,
)
from app.core.config import settings
from app.db import Base
from app.models.conversation import Conversation
from app.models.message import Message
from app.services.model_setting_service import ModelSettingService
from shared_ai_platform_sdk import SharedAIPlatformError
from shared_ai_platform_sdk.client import SharedAIPlatformClient as SharedAiPlatformSdkClient
from shared_ai_platform_sdk.types import RequestContext, SSEEvent
@@ -163,6 +169,107 @@ class RemoteSharedAiPlatformClientStreamTestCase(unittest.TestCase):
settings.shared_ai_platform_base_url = original_base_url
settings.shared_ai_platform_api_key = original_api_key
def test_runtime_config_can_be_resolved_from_model_settings(self):
original_base_url = settings.shared_ai_platform_base_url
original_api_key = settings.shared_ai_platform_api_key
settings.shared_ai_platform_base_url = ""
settings.shared_ai_platform_api_key = ""
engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
session = sessionmaker(bind=engine, future=True)()
try:
ModelSettingService(session).upsert_current_setting(
{"id": "admin-1", "tenant_id": "tenant-1", "role": "admin"},
config_format="openai_toml",
raw_config='''
model_provider = "OpenAI"
model = "gpt-5.5"
review_model = "gpt-5.5-review"
[model_providers.OpenAI]
name = "OpenAI"
base_url = "https://model.example.test"
wire_api = "responses"
requires_openai_auth = true
[shared_ai_platform]
base_url = "http://shared-ai.example.test"
api_key = "tenant-shared-ai-key"
api_prefix = "/api/v0.2"
app_id = "ai-contract-platform-test"
timeout_seconds = 42
provider = "openai"
legal_review_enabled = true
legal_review_fallback_to_local = true
document_extraction_enabled = false
''',
display_name="Tenant OpenAI",
enabled=True,
auth_token="model-key",
)
runtime_config = resolve_shared_ai_platform_runtime_config(session, "tenant-1")
self.assertIsNotNone(runtime_config)
assert runtime_config is not None
self.assertEqual(runtime_config.source, "model_settings")
self.assertEqual(runtime_config.base_url, "http://shared-ai.example.test")
self.assertEqual(runtime_config.api_key, "tenant-shared-ai-key")
self.assertEqual(runtime_config.app_id, "ai-contract-platform-test")
self.assertEqual(runtime_config.timeout_seconds, 42.0)
self.assertEqual(runtime_config.provider, "openai")
self.assertEqual(runtime_config.model, "gpt-5.5")
self.assertEqual(runtime_config.review_model, "gpt-5.5-review")
self.assertTrue(runtime_config.legal_review_fallback_to_local)
self.assertFalse(runtime_config.document_extraction_enabled)
finally:
session.close()
engine.dispose()
settings.shared_ai_platform_base_url = original_base_url
settings.shared_ai_platform_api_key = original_api_key
def test_remote_stream_includes_runtime_provider_and_model_context(self):
calls = []
class StubSdk:
def stream_conversation(self, context, *, message, chat_context=None, provider=None, template_key=None, conversation_id=None):
calls.append(
{
"message": message,
"chat_context": chat_context,
"provider": provider,
}
)
yield SSEEvent(event_type="delta", payload={"type": "delta", "content": "ok"})
yield SSEEvent(event_type="end", payload={"type": "end", "conversation_id": "conv-1"})
client = RemoteSharedAIPlatformClient(
base_url="http://testserver",
app_id="test-app",
api_key="test-key",
timeout_seconds=30.0,
runtime_config=SharedAIPlatformRuntimeConfig(
base_url="http://testserver",
api_key="test-key",
app_id="test-app",
api_prefix="/api/v0.2",
timeout_seconds=30.0,
source="model_settings",
provider="openai",
model="gpt-5.5",
review_model="gpt-5.5-review",
),
)
client.sdk = StubSdk()
chunks = list(client.stream_answer({"id": "user-1", "tenant_id": "tenant-1", "role": "admin"}, None, "hello"))
self.assertEqual(chunks[0], 'data: {"type": "text", "content": "ok"}\n\n')
self.assertEqual(calls[0]["provider"], "openai")
self.assertEqual(calls[0]["chat_context"]["modelRuntime"]["model"], "gpt-5.5")
self.assertEqual(calls[0]["chat_context"]["modelRuntime"]["review_model"], "gpt-5.5-review")
self.assertEqual(calls[0]["chat_context"]["metadata"]["modelRuntime"]["source"], "model_settings")
def test_remote_stream_ignores_start_and_usage_and_maps_delta(self):
calls = []
+3
View File
@@ -507,6 +507,7 @@ class SystemApiTestCase(unittest.TestCase):
"tenant_id": "tenant-demo-001",
"role": "admin",
}
app.dependency_overrides[get_db_session] = lambda: StubReadyDb()
client = TestClient(app)
response = client.get("/api/v1/system/shared-ai")
@@ -534,6 +535,7 @@ class SystemApiTestCase(unittest.TestCase):
"tenant_id": "tenant-demo-001",
"role": "member",
}
app.dependency_overrides[get_db_session] = lambda: StubReadyDb()
client = TestClient(app)
response = client.get("/api/v1/system/shared-ai")
@@ -558,6 +560,7 @@ class SystemApiTestCase(unittest.TestCase):
"tenant_id": "tenant-demo-001",
"role": "member",
}
app.dependency_overrides[get_db_session] = lambda: StubReadyDb()
client = TestClient(app)
response = client.get("/api/v1/system/shared-ai")
+47
View File
@@ -56,6 +56,17 @@ base_url = "https://sub2api.eqing.tech"
wire_api = "responses"
requires_openai_auth = true
[shared_ai_platform]
base_url = "https://shared-ai.example.com"
api_key = ""
app_id = "ai-contract-platform"
api_prefix = "/api/v0.2"
timeout_seconds = 30
provider = "openai"
legal_review_enabled = true
legal_review_fallback_to_local = false
document_extraction_enabled = true
[features]
goals = true`;
@@ -65,6 +76,17 @@ const ANTHROPIC_TEMPLATE = `{
"ANTHROPIC_AUTH_TOKEN": "",
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
"CLAUDE_CODE_ATTRIBUTION_HEADER": "0"
},
"shared_ai_platform": {
"base_url": "https://shared-ai.example.com",
"api_key": "",
"app_id": "ai-contract-platform",
"api_prefix": "/api/v0.2",
"timeout_seconds": 30,
"provider": "anthropic",
"legal_review_enabled": true,
"legal_review_fallback_to_local": false,
"document_extraction_enabled": true
}
}`;
@@ -95,6 +117,9 @@ function formatRuntimeExport(exportData: ModelSettingRuntimeExportResponse | nul
return "";
}
const parts = ["# Environment variables", formatEnv(exportData.env)];
if (exportData.shared_ai_platform) {
parts.push("", "# Contract platform shared AI runtime", JSON.stringify(exportData.shared_ai_platform, null, 2));
}
if (exportData.config) {
parts.push("", "# Codex/OpenAI-compatible config JSON", JSON.stringify(exportData.config, null, 2));
}
@@ -257,6 +282,11 @@ export default function ModelSettingsPage() {
<Space wrap>
{setting?.enabled ? <Tag color="green"></Tag> : <Tag color="default"></Tag>}
{setting?.has_auth_token ? <Tag color="blue"></Tag> : <Tag color="gold"></Tag>}
{setting?.shared_ai_platform?.configured ? (
<Tag color="cyan"> AI </Tag>
) : (
<Tag color="orange"> AI </Tag>
)}
</Space>
</Space>
</Card>
@@ -268,10 +298,27 @@ export default function ModelSettingsPage() {
<Descriptions.Item label="配置格式">{setting?.config_format || "-"}</Descriptions.Item>
<Descriptions.Item label="Provider">{setting?.model_provider || getProviderLabel(setting?.provider_type)}</Descriptions.Item>
<Descriptions.Item label="Base URL">{setting?.base_url || "-"}</Descriptions.Item>
<Descriptions.Item label="合同系统 AI 平台">
{setting?.shared_ai_platform?.configured ? <Tag color="green"></Tag> : <Tag color="gold"></Tag>}
</Descriptions.Item>
<Descriptions.Item label="AI 平台地址">{setting?.shared_ai_platform?.base_url || "-"}</Descriptions.Item>
<Descriptions.Item label="AI API Prefix">{setting?.shared_ai_platform?.api_prefix || "-"}</Descriptions.Item>
<Descriptions.Item label="AI App ID">{setting?.shared_ai_platform?.app_id || "-"}</Descriptions.Item>
<Descriptions.Item label="AI Provider">{setting?.shared_ai_platform?.provider || "-"}</Descriptions.Item>
<Descriptions.Item label="AI API Key">
{setting?.shared_ai_platform?.api_key_preview ||
(setting?.shared_ai_platform?.has_api_key ? "已保存" : "未保存")}
</Descriptions.Item>
<Descriptions.Item label="模型">{setting?.model || "-"}</Descriptions.Item>
<Descriptions.Item label="审查模型">{setting?.review_model || "-"}</Descriptions.Item>
<Descriptions.Item label="Wire API">{setting?.wire_api || "-"}</Descriptions.Item>
<Descriptions.Item label="Reasoning">{setting?.reasoning_effort || "-"}</Descriptions.Item>
<Descriptions.Item label="法律审查远程能力">
{setting?.shared_ai_platform?.legal_review_enabled ? "启用" : "停用"}
</Descriptions.Item>
<Descriptions.Item label="文档解析远程能力">
{setting?.shared_ai_platform?.document_extraction_enabled ? "启用" : "停用"}
</Descriptions.Item>
<Descriptions.Item label="密钥">{setting?.auth_token_preview || "未保存"}</Descriptions.Item>
<Descriptions.Item label="更新时间">{formatDateTime(setting?.updated_at)}</Descriptions.Item>
</Descriptions>
+25
View File
@@ -1633,6 +1633,25 @@ export type FileListResponse = PageResponse<FileItem>;
export type ModelSettingConfigFormat = "openai_toml" | "anthropic_env_json";
export type SharedAiPlatformRuntimeConfigSummary = {
configured: boolean;
source: string | null;
base_url: string | null;
api_prefix: string | null;
app_id: string | null;
api_key?: string;
has_api_key: boolean;
api_key_preview: string | null;
timeout_seconds: number | null;
provider: string | null;
model_provider?: string | null;
model?: string | null;
review_model?: string | null;
legal_review_enabled: boolean | null;
legal_review_fallback_to_local: boolean | null;
document_extraction_enabled: boolean | null;
};
export type ModelSettingResponse = {
id: string | null;
tenant_id: string;
@@ -1650,6 +1669,7 @@ export type ModelSettingResponse = {
has_auth_token: boolean;
auth_token_preview: string | null;
config_json: Record<string, unknown>;
shared_ai_platform: SharedAiPlatformRuntimeConfigSummary;
raw_config: string;
created_at: string | null;
updated_at: string | null;
@@ -1668,6 +1688,7 @@ export type ModelSettingRuntimeExportResponse = {
provider_type: "openai" | "anthropic" | string | null;
env: Record<string, string>;
config: Record<string, unknown> | null;
shared_ai_platform: SharedAiPlatformRuntimeConfigSummary;
};
export type SharedAiMode = "configuration-required" | "remote-preferred";
@@ -1679,6 +1700,10 @@ export type SharedAiStatusResponse = {
api_prefix: string;
app_id: string;
timeout_seconds: number;
config_source?: string | null;
provider?: string | null;
model?: string | null;
review_model?: string | null;
transport: {
sdk: string;
fallback: string;