Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions backend/app/api/model_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
66 changes: 47 additions & 19 deletions backend/app/llm/client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -23,6 +23,7 @@
CancellationToken,
ChatCompletionsDriver,
GeminiGenerateContentDriver,
OpenAIResponsesDriver,
ProtocolCallError,
)
from app.llm.stage_protocol import (
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 = (
Expand All @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (
Expand All @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
84 changes: 84 additions & 0 deletions backend/app/llm/codex_config.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 18 additions & 1 deletion backend/app/llm/model_protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,17 @@

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


class ModelApiProtocol(StrEnum):
OPENAI_CHAT_COMPLETIONS = "openai_chat_completions"
OPENAI_RESPONSES = "openai_responses"
ANTHROPIC_MESSAGES = "anthropic_messages"
GEMINI_GENERATE_CONTENT = "gemini_generate_content"

Expand Down Expand Up @@ -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 {}
Expand Down
Loading