From 8ff4ed702e7b39a6638effc613f6b3e8f2a616c3 Mon Sep 17 00:00:00 2001 From: lanicc <1728209643@qq.com> Date: Fri, 31 Jul 2026 11:44:33 +0800 Subject: [PATCH] feat(llm): import and reuse local codex model config --- backend/app/api/model_configs.py | 80 ++++++++ backend/app/llm/client.py | 66 +++++-- backend/app/llm/codex_config.py | 84 ++++++++ backend/app/llm/model_protocols.py | 19 +- backend/app/llm/protocol_drivers.py | 180 +++++++++++++++++- backend/tests/test_model_configs_api.py | 96 +++++++++- backend/tests/test_model_protocols.py | 13 +- backend/tests/test_openai_responses_driver.py | 152 +++++++++++++++ frontend-enterprise/src/pages/ModelsPage.tsx | 33 +++- .../chat/components/ModelSetupDialog.tsx | 7 +- frontend-enterprise/src/types/index.ts | 2 +- 11 files changed, 700 insertions(+), 32 deletions(-) create mode 100644 backend/app/llm/codex_config.py create mode 100644 backend/tests/test_openai_responses_driver.py diff --git a/backend/app/api/model_configs.py b/backend/app/api/model_configs.py index 772fbdc5..6dc8e934 100644 --- a/backend/app/api/model_configs.py +++ b/backend/app/api/model_configs.py @@ -12,6 +12,7 @@ from app.db import get_session from app.db.models import ModelConfig, User, utc_now from app.llm import LLMClient, LLMError +from app.llm.codex_config import load_local_codex_model_config from app.llm.model_config_resolver import resolve_model_config_for_verification from app.llm.model_protocols import ( LEGACY_OPENAI_PROVIDER, @@ -20,6 +21,7 @@ current_protocol_options, model_config_fingerprint, normalize_chat_protocol_options, + normalize_responses_protocol_options, resolve_api_protocol, validate_model_base_url, ) @@ -132,6 +134,80 @@ def create_model_config( return model_config_read(row) +@router.post("/import-codex", response_model=ModelConfigRead) +def import_local_codex_model_config( + tenant_id: str = Query(...), + db: Session = Depends(get_session), + current_user: User = Depends(get_current_user), +) -> ModelConfigRead: + ensure_tenant_admin(tenant_id, current_user) + ensure_tenant(db, tenant_id) + source = load_local_codex_model_config() + protocol = source["api_protocol"] + validate_model_base_url(source["base_url"]) + source_options = dict(source["protocol_options"]) + matching_rows = db.exec( + select(ModelConfig).where( + ModelConfig.tenant_id == tenant_id, + ModelConfig.name == source["name"], + ) + ).all() + row = max(matching_rows, key=lambda candidate: (candidate.updated_at, candidate.id), default=None) + if row is None: + row = ModelConfig( + tenant_id=tenant_id, + name=source["name"], + provider=LEGACY_OPENAI_PROVIDER, + api_protocol=protocol.value, + base_url=source["base_url"], + api_key_encrypted=encrypt_secret(source["api_key"]), + model=source["model"], + temperature=0.2, + max_output_tokens=8192, + extra_body_json=source_options, + protocol_options_json={protocol.value: source_options}, + is_default=False, + enabled=False, + trust_status="unverified", + ) + else: + key_changed = decrypt_secret(row.api_key_encrypted) != source["api_key"] + security_changed = ( + row.api_protocol != protocol.value + or row.base_url != source["base_url"] + or row.model != source["model"] + or key_changed + or current_protocol_options(row.protocol_options_json, protocol) != source_options + ) + if not security_changed: + return model_config_read(row) + row.provider = LEGACY_OPENAI_PROVIDER + row.api_protocol = protocol.value + row.base_url = source["base_url"] + row.model = source["model"] + row.extra_body_json = source_options + row.protocol_options_json = {protocol.value: source_options} + if key_changed: + row.api_key_encrypted = encrypt_secret(source["api_key"]) + row.key_revision += 1 + row.config_revision += 1 + row.security_revision += 1 + row.trust_status = "unverified" + row.verified_at = None + row.verified_fingerprint = None + row.verification_attempt_id = None + row.verification_started_at = None + row.verification_attempt_status = "idle" + row.verification_attempt_error_code = None + row.enabled = False + row.is_default = False + row.updated_at = utc_now() + db.add(row) + _commit_or_conflict(db) + db.refresh(row) + return model_config_read(row) + + @router.put("/{config_id}", response_model=ModelConfigRead) def update_model_config( config_id: str, @@ -476,6 +552,10 @@ def _request_protocol_options( if (protocol_options is not None and protocol_options != {}) or extra_body: raise HTTPException(status_code=422, detail="MODEL_PROTOCOL_OPTIONS_INVALID") return {} + if protocol is ModelApiProtocol.OPENAI_RESPONSES: + if protocol_options is not None: + return normalize_responses_protocol_options(protocol_options) + return normalize_responses_protocol_options(extra_body) if protocol_options is not None: return normalize_chat_protocol_options(protocol_options) return normalize_chat_protocol_options(extra_body) diff --git a/backend/app/llm/client.py b/backend/app/llm/client.py index f9951fa0..31a59517 100644 --- a/backend/app/llm/client.py +++ b/backend/app/llm/client.py @@ -1,18 +1,18 @@ from __future__ import annotations import ast -from collections.abc import Iterator, Mapping import copy import hashlib import json import math import re +from collections.abc import Iterator, Mapping from typing import Any from urllib.parse import urlsplit import httpx -from openai import OpenAI from anthropic import Anthropic +from openai import OpenAI from app.config import get_settings from app.db.models import ModelConfig @@ -23,6 +23,7 @@ CancellationToken, ChatCompletionsDriver, GeminiGenerateContentDriver, + OpenAIResponsesDriver, ProtocolCallError, ) from app.llm.stage_protocol import ( @@ -85,13 +86,20 @@ def __init__(self, model_config: ModelConfig): or DEFAULT_MODEL_API_TIMEOUT_SECONDS ) self.base_url = str(model_config.base_url or "") - if protocol is ModelApiProtocol.OPENAI_CHAT_COMPLETIONS: + if protocol in { + ModelApiProtocol.OPENAI_CHAT_COMPLETIONS, + ModelApiProtocol.OPENAI_RESPONSES, + }: self.client = OpenAI( api_key=api_key, base_url=self.base_url, timeout=self.timeout_seconds, ) - self.driver = ChatCompletionsDriver(self.client) + self.driver = ( + ChatCompletionsDriver(self.client) + if protocol is ModelApiProtocol.OPENAI_CHAT_COMPLETIONS + else OpenAIResponsesDriver(self.client) + ) elif protocol is ModelApiProtocol.ANTHROPIC_MESSAGES: kwargs: dict[str, Any] = { "api_key": api_key, @@ -119,10 +127,15 @@ def __init__(self, model_config: ModelConfig): self.max_output_tokens = model_config.max_output_tokens legacy_extra_body = getattr(model_config, "legacy_extra_body", {}) protocol_options = getattr(model_config, "protocol_options", {}) + self.protocol_options = _normalize_extra_body(protocol_options) self.extra_body = _normalize_extra_body( legacy_extra_body or getattr(model_config, "extra_body_json", {}) - or protocol_options + or ( + self.protocol_options + if protocol is ModelApiProtocol.OPENAI_CHAT_COMPLETIONS + else {} + ) ) settings = get_settings() self.thinking_mode = ( @@ -132,7 +145,7 @@ def __init__(self, model_config: ModelConfig): getattr(settings, "model_thinking_models", ""), self.model, ) - ) + ) if protocol is ModelApiProtocol.OPENAI_CHAT_COMPLETIONS else "" def generate_text( self, @@ -167,12 +180,7 @@ def generate_text( request["_cancellation"] = cancellation if response_format: request["response_format"] = response_format - request.update( - _thinking_request_kwargs( - getattr(self, "thinking_mode", ""), - getattr(self, "extra_body", {}), - ) - ) + request.update(self._protocol_request_options()) empty_diagnostics: list[str] = [] current_max_tokens = max_output_tokens for attempt in range(EMPTY_RESPONSE_RETRIES + 1): @@ -292,10 +300,7 @@ def generate_text_stream( "messages": request_messages, "temperature": self.temperature, "max_tokens": current_max_tokens, - **_thinking_request_kwargs( - getattr(self, "thinking_mode", ""), - getattr(self, "extra_body", {}), - ), + **self._protocol_request_options(), } if cancellation is not None: stream_request["_cancellation"] = cancellation @@ -404,7 +409,12 @@ def generate_text_stream( def _protocol_driver( self, - ) -> ChatCompletionsDriver | AnthropicMessagesDriver | GeminiGenerateContentDriver: + ) -> ( + ChatCompletionsDriver + | OpenAIResponsesDriver + | AnthropicMessagesDriver + | GeminiGenerateContentDriver + ): driver = getattr(self, "driver", None) if driver is None: if getattr(self, "api_protocol", ModelApiProtocol.OPENAI_CHAT_COMPLETIONS) is ( @@ -416,11 +426,27 @@ def _protocol_driver( getattr(self, "api_key", ""), self.model, ) + elif getattr(self, "api_protocol", ModelApiProtocol.OPENAI_CHAT_COMPLETIONS) is ( + ModelApiProtocol.OPENAI_RESPONSES + ): + driver = OpenAIResponsesDriver(self.client) else: driver = ChatCompletionsDriver(self.client) self.driver = driver return driver + def _protocol_request_options(self) -> dict[str, Any]: + protocol = getattr(self, "api_protocol", ModelApiProtocol.OPENAI_CHAT_COMPLETIONS) + if protocol is ModelApiProtocol.OPENAI_CHAT_COMPLETIONS: + return _thinking_request_kwargs( + getattr(self, "thinking_mode", ""), + getattr(self, "extra_body", {}), + ) + if protocol is ModelApiProtocol.OPENAI_RESPONSES: + options = getattr(self, "protocol_options", {}) + return {"protocol_options": options} if options else {} + return {} + def generate_json( self, system_prompt: str, @@ -506,8 +532,10 @@ def call_generate_text(prompt: str, payload: dict[str, Any], **kwargs: Any) -> s kwargs["cancellation"] = cancellation return self.generate_text(prompt, payload, **kwargs) - if getattr(self, "api_protocol", ModelApiProtocol.OPENAI_CHAT_COMPLETIONS) is ( - ModelApiProtocol.ANTHROPIC_MESSAGES + protocol = getattr(self, "api_protocol", ModelApiProtocol.OPENAI_CHAT_COMPLETIONS) + if protocol is ModelApiProtocol.ANTHROPIC_MESSAGES or ( + protocol is ModelApiProtocol.OPENAI_RESPONSES + and getattr(self, "protocol_options", {}).get("json_mode", "prompt") != "native" ): return call_generate_text( system_prompt.rstrip() diff --git a/backend/app/llm/codex_config.py b/backend/app/llm/codex_config.py new file mode 100644 index 00000000..8147bcc4 --- /dev/null +++ b/backend/app/llm/codex_config.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import os +import tomllib +from pathlib import Path +from typing import Any + +from fastapi import HTTPException + +from app.llm.model_protocols import ModelApiProtocol + +_CODEX_CONFIG_MAX_BYTES = 1024 * 1024 +_WIRE_API_PROTOCOLS = { + "chat": ModelApiProtocol.OPENAI_CHAT_COMPLETIONS, + "responses": ModelApiProtocol.OPENAI_RESPONSES, +} + + +def load_local_codex_model_config() -> dict[str, Any]: + config_path = _codex_config_path() + try: + if not config_path.is_file(): + raise FileNotFoundError + if config_path.stat().st_size > _CODEX_CONFIG_MAX_BYTES: + raise ValueError + with config_path.open("rb") as config_file: + config = tomllib.load(config_file) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="CODEX_CONFIG_NOT_FOUND") from exc + except (OSError, tomllib.TOMLDecodeError, ValueError) as exc: + raise HTTPException(status_code=422, detail="CODEX_CONFIG_INVALID") from exc + + provider_id = _required_string(config.get("model_provider"), "CODEX_CONFIG_PROVIDER_MISSING") + model = _required_string(config.get("model"), "CODEX_CONFIG_MODEL_MISSING") + providers = config.get("model_providers") + if not isinstance(providers, dict): + raise HTTPException(status_code=422, detail="CODEX_CONFIG_PROVIDER_MISSING") + provider = providers.get(provider_id) + if not isinstance(provider, dict): + raise HTTPException(status_code=422, detail="CODEX_CONFIG_PROVIDER_MISSING") + + wire_api = _required_string(provider.get("wire_api"), "CODEX_CONFIG_WIRE_API_MISSING") + protocol = _WIRE_API_PROTOCOLS.get(wire_api) + if protocol is None: + raise HTTPException(status_code=422, detail="CODEX_CONFIG_WIRE_API_UNSUPPORTED") + + base_url = _required_string(provider.get("base_url"), "CODEX_CONFIG_BASE_URL_MISSING") + bearer_token = _required_string( + provider.get("experimental_bearer_token"), "CODEX_CONFIG_API_KEY_UNAVAILABLE" + ) + provider_name = _optional_string(provider.get("name")) or provider_id + protocol_options: dict[str, Any] = {} + if protocol is ModelApiProtocol.OPENAI_RESPONSES: + protocol_options["store"] = not bool(config.get("disable_response_storage")) + protocol_options["json_mode"] = "prompt" + + return { + "name": f"Codex {provider_name}", + "api_protocol": protocol, + "base_url": base_url, + "api_key": bearer_token, + "model": model, + "protocol_options": protocol_options, + } + + +def _codex_config_path() -> Path: + codex_home = os.environ.get("CODEX_HOME") + home = Path(codex_home).expanduser() if codex_home else Path.home() / ".codex" + return home / "config.toml" + + +def _required_string(value: Any, detail: str) -> str: + normalized = _optional_string(value) + if normalized is None: + raise HTTPException(status_code=422, detail=detail) + return normalized + + +def _optional_string(value: Any) -> str | None: + if not isinstance(value, str): + return None + normalized = value.strip() + return normalized or None diff --git a/backend/app/llm/model_protocols.py b/backend/app/llm/model_protocols.py index 4dafb995..cd89fb57 100644 --- a/backend/app/llm/model_protocols.py +++ b/backend/app/llm/model_protocols.py @@ -2,9 +2,9 @@ import hashlib import json +import unicodedata from enum import StrEnum from typing import Any -import unicodedata from urllib.parse import urlsplit, urlunsplit from fastapi import HTTPException @@ -12,6 +12,7 @@ class ModelApiProtocol(StrEnum): OPENAI_CHAT_COMPLETIONS = "openai_chat_completions" + OPENAI_RESPONSES = "openai_responses" ANTHROPIC_MESSAGES = "anthropic_messages" GEMINI_GENERATE_CONTENT = "gemini_generate_content" @@ -55,6 +56,22 @@ def normalize_chat_protocol_options(value: Any) -> dict[str, Any]: return {"thinking": dict(thinking)} +def normalize_responses_protocol_options(value: Any) -> dict[str, Any]: + if value is None: + return {} + if not isinstance(value, dict) or set(value) - {"store", "json_mode"}: + raise HTTPException(status_code=422, detail="MODEL_PROTOCOL_OPTIONS_INVALID") + if "store" in value and not isinstance(value["store"], bool): + raise HTTPException(status_code=422, detail="MODEL_PROTOCOL_OPTIONS_INVALID") + if value.get("json_mode") not in {None, "native", "prompt"}: + raise HTTPException(status_code=422, detail="MODEL_PROTOCOL_OPTIONS_INVALID") + return { + key: value[key] + for key in ("store", "json_mode") + if key in value + } + + def current_protocol_options(protocol_options: Any, protocol: ModelApiProtocol) -> dict[str, Any]: if not isinstance(protocol_options, dict): return {} diff --git a/backend/app/llm/protocol_drivers.py b/backend/app/llm/protocol_drivers.py index 5c3d46e0..705fcec3 100644 --- a/backend/app/llm/protocol_drivers.py +++ b/backend/app/llm/protocol_drivers.py @@ -1,19 +1,18 @@ from __future__ import annotations -from collections.abc import Iterator -from dataclasses import dataclass import base64 import binascii import json import re -from types import SimpleNamespace +from collections.abc import Iterator +from dataclasses import dataclass from threading import Event +from types import SimpleNamespace from typing import Any, Protocol from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit import httpx - _DATA_URL = re.compile(r"^data:(image/(?:jpeg|png|gif|webp));base64,(.+)$", re.DOTALL) _MAX_IMAGE_BYTES = 5 * 1024 * 1024 _MAX_IMAGE_COUNT = 6 @@ -74,6 +73,70 @@ def iterate() -> Iterator[Any]: return iterate() +@dataclass(frozen=True) +class OpenAIResponsesDriver: + client: Any + request_kind: str = "responses" + + def complete(self, request: dict[str, Any]) -> Any: + _raise_if_cancelled(request) + try: + response = self.client.responses.create( + **_responses_request(request), + stream=False, + ) + except Exception as exc: + raise _protocol_call_error(exc) from exc + return _responses_completion(response) + + def stream(self, request: dict[str, Any]) -> Iterator[Any]: + _raise_if_cancelled(request) + try: + stream = self.client.responses.create(**_responses_request(request), stream=True) + except Exception as exc: + raise _protocol_call_error(exc) from exc + + def iterate() -> Iterator[Any]: + response_id = None + try: + for event in stream: + _raise_if_cancelled(request) + event_type = str(getattr(event, "type", "")) + if event_type == "response.created": + response_id = getattr(getattr(event, "response", None), "id", None) + continue + if event_type == "response.output_text.delta": + yield _stream_chunk( + response_id, + text=str(getattr(event, "delta", "") or ""), + ) + continue + if event_type == "response.completed": + response = getattr(event, "response", None) + response_id = getattr(response, "id", None) or response_id + completion = _responses_completion(response) + yield _stream_chunk( + response_id, + finish_reason=getattr(completion.choices[0], "finish_reason", None) + if completion.choices + else "completed", + usage=getattr(completion, "usage", None), + ) + continue + if event_type == "response.failed": + raise ProtocolCallError("MODEL_UPSTREAM_ERROR") + except ProtocolCallError: + raise + except Exception as exc: + raise _protocol_call_error(exc) from exc + finally: + close = getattr(stream, "close", None) + if callable(close): + close() + + return iterate() + + @dataclass(frozen=True) class AnthropicMessagesDriver: client: Any @@ -213,6 +276,115 @@ def _gemini_headers(api_key: str) -> dict[str, str]: } +def _responses_request(request: dict[str, Any]) -> dict[str, Any]: + input_items: list[dict[str, Any]] = [] + instructions: list[str] = [] + for message in request.get("messages") or []: + role = str(message.get("role") or "") + if role == "system": + text = _content_text(message.get("content")) + if text: + instructions.append(text) + continue + if role not in {"user", "assistant", "developer"}: + continue + content = _responses_content(message.get("content"), role) + if content: + input_items.append({"role": role, "content": content}) + + payload: dict[str, Any] = { + "model": request["model"], + "input": input_items, + "temperature": request["temperature"], + "max_output_tokens": request["max_tokens"], + } + if instructions: + payload["instructions"] = "\n\n".join(instructions) + response_format = request.get("response_format") + if response_format and response_format.get("type") == "json_object": + payload["text"] = {"format": {"type": "json_object"}} + protocol_options = request.get("protocol_options") + if isinstance(protocol_options, dict) and "store" in protocol_options: + payload["store"] = protocol_options["store"] + if len(json.dumps(payload, ensure_ascii=False).encode("utf-8")) > _MAX_REQUEST_BYTES: + raise ValueError("MODEL_REQUEST_TOO_LARGE") + return payload + + +def _responses_content(value: Any, role: str) -> list[dict[str, Any]]: + if isinstance(value, str): + return [_responses_text_part(value, role)] if value else [] + if not isinstance(value, list): + return [] + parts: list[dict[str, Any]] = [] + image_count = 0 + for item in value: + if not isinstance(item, dict): + continue + if item.get("type") == "text": + text = str(item.get("text") or "") + if text: + parts.append(_responses_text_part(text, role)) + continue + if item.get("type") != "image_url" or role != "user": + continue + image = item.get("image_url") + url = str(image.get("url") or "") if isinstance(image, dict) else "" + if not url: + continue + image_count += 1 + if image_count > _MAX_IMAGE_COUNT: + raise ValueError("MODEL_TOO_MANY_IMAGES") + parts.append({"type": "input_image", "image_url": url}) + return parts + + +def _responses_text_part(text: str, role: str) -> dict[str, Any]: + return {"type": "output_text" if role == "assistant" else "input_text", "text": text} + + +def _responses_completion(response: Any) -> Any: + text = str(getattr(response, "output_text", "") or "") + if not text: + text = _responses_output_text(response) + status = getattr(response, "status", None) + choices = [ + SimpleNamespace( + message=SimpleNamespace(content=text), + finish_reason=status, + ) + ] if text or status else [] + return SimpleNamespace( + id=getattr(response, "id", None), + usage=_responses_usage(getattr(response, "usage", None)), + choices=choices, + ) + + +def _responses_output_text(response: Any) -> str: + parts: list[str] = [] + for item in getattr(response, "output", None) or []: + for content in getattr(item, "content", None) or []: + if getattr(content, "type", None) == "output_text": + text = getattr(content, "text", None) + if isinstance(text, str): + parts.append(text) + return "".join(parts) + + +def _responses_usage(value: Any) -> Any: + if value is None: + return None + input_tokens = getattr(value, "input_tokens", None) + output_tokens = getattr(value, "output_tokens", None) + return SimpleNamespace( + prompt_tokens=input_tokens, + completion_tokens=output_tokens, + total_tokens=getattr(value, "total_tokens", None), + prompt_tokens_details=getattr(value, "input_tokens_details", None), + ) + + def _gemini_endpoint( base_url: str, model: str, method: str, *, stream: bool = False ) -> str: diff --git a/backend/tests/test_model_configs_api.py b/backend/tests/test_model_configs_api.py index abb39ccf..9d84bc72 100644 --- a/backend/tests/test_model_configs_api.py +++ b/backend/tests/test_model_configs_api.py @@ -5,11 +5,12 @@ from fastapi import HTTPException from sqlalchemy import text -from sqlmodel import Session, SQLModel, create_engine +from sqlmodel import Session, SQLModel, create_engine, select from app.api.model_configs import ( _verification_probe_tokens, create_model_config, + import_local_codex_model_config, set_default_model_config, update_model_config, ) @@ -82,6 +83,99 @@ def test_gemini_model_config_can_be_created(tmp_path) -> None: assert created.protocol_options == {} +def test_codex_config_import_creates_unverified_responses_model(tmp_path, monkeypatch) -> None: + codex_home = tmp_path / "codex" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + """model_provider = \"local\" +model = \"gpt-test\" +disable_response_storage = true + +[model_providers.local] +name = \"Local relay\" +base_url = \"http://127.0.0.1:15721/v1\" +wire_api = \"responses\" +experimental_bearer_token = \"relay-token\" +""", + encoding="utf-8", + ) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + with _db(tmp_path) as db: + imported = import_local_codex_model_config( + tenant_id="tenant_a", + db=db, + current_user=_admin(), + ) + + row = db.get(ModelConfig, imported.id) + assert row is not None + assert imported.name == "Codex Local relay" + assert imported.api_protocol == "openai_responses" + assert imported.protocol_options == {"store": False, "json_mode": "prompt"} + assert imported.enabled is False + assert imported.is_default is False + assert imported.trust_status == "unverified" + assert row.api_key_encrypted != "relay-token" + + +def test_codex_config_import_updates_matching_name_without_duplicate(tmp_path, monkeypatch) -> None: + codex_home = tmp_path / "codex" + codex_home.mkdir() + (codex_home / "config.toml").write_text( + """model_provider = "local" +model = "gpt-new" +disable_response_storage = true + +[model_providers.local] +name = "Local relay" +base_url = "http://127.0.0.1:15721/v1" +wire_api = "responses" +experimental_bearer_token = "new-token" +""", + encoding="utf-8", + ) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + with _db(tmp_path) as db: + db.add( + ModelConfig( + id="model_existing", + tenant_id="tenant_a", + name="Codex Local relay", + api_key_encrypted=encrypt_secret("old-token"), + model="gpt-old", + trust_status="verified", + verified_fingerprint="old-fingerprint", + enabled=True, + is_default=True, + ) + ) + db.commit() + + imported = import_local_codex_model_config( + tenant_id="tenant_a", + db=db, + current_user=_admin(), + ) + + rows = db.exec( + select(ModelConfig).where( + ModelConfig.tenant_id == "tenant_a", + ModelConfig.name == "Codex Local relay", + ) + ).all() + assert len(rows) == 1 + assert imported.id == "model_existing" + assert imported.model == "gpt-new" + assert imported.api_protocol == "openai_responses" + assert imported.enabled is False + assert imported.is_default is False + assert imported.trust_status == "unverified" + assert imported.config_revision == 2 + assert imported.security_revision == 2 + + def test_gemini_verification_reserves_tokens_for_visible_output() -> None: from app.llm.model_protocols import ModelApiProtocol diff --git a/backend/tests/test_model_protocols.py b/backend/tests/test_model_protocols.py index 01e1991d..7ed331a0 100644 --- a/backend/tests/test_model_protocols.py +++ b/backend/tests/test_model_protocols.py @@ -8,16 +8,17 @@ from sqlmodel import Session, SQLModel, create_engine from app.db.models import ModelConfig +from app.llm.client import _normalize_extra_body from app.llm.model_config_resolver import ( resolve_model_config_for_runtime, resolve_model_config_for_verification, ) -from app.llm.client import _normalize_extra_body from app.llm.model_protocols import ( ModelApiProtocol, available_model_protocols, model_config_fingerprint, normalize_chat_protocol_options, + normalize_responses_protocol_options, resolve_api_protocol, ) @@ -69,6 +70,15 @@ def test_chat_thinking_options_are_strictly_typed() -> None: assert exc_info.value.detail == "MODEL_PROTOCOL_OPTIONS_INVALID" +def test_responses_options_allow_only_store_flag() -> None: + assert normalize_responses_protocol_options( + {"store": False, "json_mode": "prompt"} + ) == {"store": False, "json_mode": "prompt"} + with pytest.raises(HTTPException) as exc_info: + normalize_responses_protocol_options({"thinking": {"type": "disabled"}}) + assert exc_info.value.detail == "MODEL_PROTOCOL_OPTIONS_INVALID" + + def test_fingerprint_normalizes_equivalent_base_urls() -> None: common = { "api_protocol": "openai_chat_completions", @@ -133,6 +143,7 @@ def test_verified_runtime_requires_matching_fingerprint() -> None: def test_all_implemented_protocols_are_available() -> None: assert available_model_protocols() == [ "openai_chat_completions", + "openai_responses", "anthropic_messages", "gemini_generate_content", ] diff --git a/backend/tests/test_openai_responses_driver.py b/backend/tests/test_openai_responses_driver.py new file mode 100644 index 00000000..d712010d --- /dev/null +++ b/backend/tests/test_openai_responses_driver.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from types import SimpleNamespace + +from app.llm.client import LLMClient +from app.llm.model_protocols import ModelApiProtocol +from app.llm.protocol_drivers import OpenAIResponsesDriver + + +class _Responses: + def __init__(self, response, events=None) -> None: + self.calls = [] + self.response = response + self.events = events or [] + + def create(self, **kwargs): + self.calls.append(kwargs) + return self.events if kwargs.get("stream") else self.response + + +class _ClosableEvents(list): + def __init__(self, values) -> None: + super().__init__(values) + self.closed = False + + def close(self) -> None: + self.closed = True + + +def _response(text: str = "ok") -> SimpleNamespace: + return SimpleNamespace( + id="resp_123", + output_text=text, + status="completed", + usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6), + ) + + +def test_responses_driver_maps_messages_json_and_storage() -> None: + responses = _Responses(_response("hello")) + driver = OpenAIResponsesDriver(SimpleNamespace(responses=responses)) + + result = driver.complete( + { + "model": "gpt-test", + "messages": [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "previous"}, + ], + "temperature": 0.2, + "max_tokens": 128, + "response_format": {"type": "json_object"}, + "protocol_options": {"store": False}, + } + ) + + assert result.choices[0].message.content == "hello" + assert result.usage.total_tokens == 6 + assert responses.calls[0] == { + "model": "gpt-test", + "input": [ + {"role": "user", "content": [{"type": "input_text", "text": "hello"}]}, + {"role": "assistant", "content": [{"type": "output_text", "text": "previous"}]}, + ], + "temperature": 0.2, + "max_output_tokens": 128, + "instructions": "system", + "text": {"format": {"type": "json_object"}}, + "store": False, + "stream": False, + } + + +def test_responses_driver_maps_stream_events_and_closes_stream() -> None: + response = _response() + events = _ClosableEvents( + [ + SimpleNamespace(type="response.created", response=SimpleNamespace(id="resp_stream")), + SimpleNamespace(type="response.output_text.delta", delta="hi"), + SimpleNamespace(type="response.completed", response=response), + ] + ) + responses = _Responses(response, events) + driver = OpenAIResponsesDriver(SimpleNamespace(responses=responses)) + + chunks = list( + driver.stream( + { + "model": "gpt-test", + "messages": [{"role": "user", "content": "hello"}], + "temperature": 0.2, + "max_tokens": 128, + } + ) + ) + + assert chunks[0].id == "resp_stream" + assert chunks[0].choices[0].delta.content == "hi" + assert chunks[1].choices[0].finish_reason == "completed" + assert chunks[1].usage.total_tokens == 6 + assert responses.calls[0]["stream"] is True + assert events.closed is True + + +def test_llm_client_uses_responses_driver_and_storage_option() -> None: + client = object.__new__(LLMClient) + client.api_protocol = ModelApiProtocol.OPENAI_RESPONSES + client.client = SimpleNamespace(responses=_Responses(_response())) + client.model = "gpt-test" + client.temperature = 0.2 + client.max_output_tokens = 128 + client.protocol_options = {"store": False} + + assert client.generate_text("system", {"hello": "world"}) == "ok" + call = client.client.responses.calls[0] + assert call["store"] is False + assert call["max_output_tokens"] == 128 + + +def test_responses_json_uses_prompt_instead_of_text_format(monkeypatch) -> None: + client = object.__new__(LLMClient) + client.api_protocol = ModelApiProtocol.OPENAI_RESPONSES + client.protocol_options = {"json_mode": "prompt"} + calls = [] + + def fake_generate_text(system_prompt, payload, response_format=None): # noqa: ANN001 + calls.append((system_prompt, payload, response_format)) + return '{"ok": true}' + + monkeypatch.setattr(client, "generate_text", fake_generate_text) + + assert client.generate_json("system", {"task": "json"}) == {"ok": True} + assert "只返回一个合法 JSON object" in calls[0][0] + assert calls[0][2] is None + + +def test_legacy_responses_json_defaults_to_prompt_mode(monkeypatch) -> None: + client = object.__new__(LLMClient) + client.api_protocol = ModelApiProtocol.OPENAI_RESPONSES + client.protocol_options = {"store": False} + calls = [] + + def fake_generate_text(system_prompt, payload, response_format=None): # noqa: ANN001 + calls.append((system_prompt, payload, response_format)) + return '{"ok": true}' + + monkeypatch.setattr(client, "generate_text", fake_generate_text) + + assert client.generate_json("system", {"task": "json"}) == {"ok": True} + assert "只返回一个合法 JSON object" in calls[0][0] + assert calls[0][2] is None diff --git a/frontend-enterprise/src/pages/ModelsPage.tsx b/frontend-enterprise/src/pages/ModelsPage.tsx index 044c1c91..c8f19556 100644 --- a/frontend-enterprise/src/pages/ModelsPage.tsx +++ b/frontend-enterprise/src/pages/ModelsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { Check, FlaskConical, LoaderCircle } from 'lucide-react'; +import { Check, FlaskConical, Import, LoaderCircle } from 'lucide-react'; import { api, TENANT_ID } from '../api/client'; import type { EnterpriseAuthUser } from '../auth'; @@ -44,7 +44,7 @@ const MODEL_PAGE_SIZE = 8; type ModelForm = { name: string; - api_protocol: 'openai_chat_completions' | 'anthropic_messages' | 'gemini_generate_content'; + api_protocol: 'openai_chat_completions' | 'openai_responses' | 'anthropic_messages' | 'gemini_generate_content'; base_url: string; model: string; api_key: string; @@ -96,6 +96,7 @@ export default function ModelsPage({ const [selected, setSelected] = useState(null); const [editorOpen, setEditorOpen] = useState(false); const [saving, setSaving] = useState(false); + const [importingCodex, setImportingCodex] = useState(false); const testingModelIdsRef = useRef(new Set()); const [testingModelIds, setTestingModelIds] = useState>(new Set()); const [form, setForm] = useState(BLANK_MODEL_FORM); @@ -257,6 +258,20 @@ export default function ModelsPage({ notify.error(modelActionError(error, '设为默认失败')); } } + async function importCodex() { + setImportingCodex(true); + try { + const imported = await api.post( + `/api/enterprise/model-configs/import-codex?tenant_id=${TENANT_ID}`, + ); + notify.success(`已导入 ${imported.name},请测试后启用`); + await load(); + } catch (error) { + notify.error(error instanceof Error ? error.message : '导入 Codex 配置失败'); + } finally { + setImportingCodex(false); + } + } async function test(row: ModelConfigRead): Promise { if (testingModelIdsRef.current.has(row.id)) return false; @@ -395,6 +410,15 @@ export default function ModelsPage({ 刷新 + void importCodex()} + disabled={importingCodex} + className="h-[34px] gap-[4px] rounded-[10px] border-[0.5px] border-[#e3e7f1] bg-white px-[20px] text-[12px] font-normal text-[#757f9c] hover:border-[#cbd3e6] hover:bg-white hover:text-[#18181a]" + > + {importingCodex ? : } + 导入 Codex + OpenAI Chat Completions )} + {availableProtocols.includes('openai_responses') && ( + OpenAI Responses + )} {availableProtocols.includes('anthropic_messages') && ( Anthropic Messages )} @@ -513,7 +540,7 @@ export default function ModelsPage({ updateForm('base_url', event.target.value)} diff --git a/frontend-enterprise/src/pages/chat/components/ModelSetupDialog.tsx b/frontend-enterprise/src/pages/chat/components/ModelSetupDialog.tsx index 9ce6479f..a272958e 100644 --- a/frontend-enterprise/src/pages/chat/components/ModelSetupDialog.tsx +++ b/frontend-enterprise/src/pages/chat/components/ModelSetupDialog.tsx @@ -31,7 +31,7 @@ type ModelSetupDialogProps = { type ModelSetupForm = { name: string; - apiProtocol: 'openai_chat_completions' | 'anthropic_messages' | 'gemini_generate_content'; + apiProtocol: 'openai_chat_completions' | 'openai_responses' | 'anthropic_messages' | 'gemini_generate_content'; baseUrl: string; model: string; apiKey: string; @@ -181,6 +181,9 @@ export default function ModelSetupDialog({ {availableProtocols.includes('openai_chat_completions') && ( OpenAI Chat Completions )} + {availableProtocols.includes('openai_responses') && ( + OpenAI Responses + )} {availableProtocols.includes('anthropic_messages') && ( Anthropic Messages )} @@ -193,7 +196,7 @@ export default function ModelSetupDialog({ updateForm('baseUrl', event.target.value)} diff --git a/frontend-enterprise/src/types/index.ts b/frontend-enterprise/src/types/index.ts index 91e40e78..0c860834 100644 --- a/frontend-enterprise/src/types/index.ts +++ b/frontend-enterprise/src/types/index.ts @@ -296,7 +296,7 @@ export type ModelConfigRead = { tenant_id: string; name: string; provider: string; - api_protocol: 'openai_chat_completions' | 'anthropic_messages' | 'gemini_generate_content'; + api_protocol: 'openai_chat_completions' | 'openai_responses' | 'anthropic_messages' | 'gemini_generate_content'; base_url?: string; api_key_masked: string; model: string;