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
3 changes: 2 additions & 1 deletion app/core/openai/model_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,6 @@ def _bootstrap_model(
context_window=128_000,
input_modalities=("text",),
default_reasoning_level="high",
supported_in_api=False,
minimal_client_version="0.100.0",
),
_bootstrap_model(
Expand Down Expand Up @@ -312,6 +311,8 @@ def get_model_registry() -> ModelRegistry:


def is_public_model(model: UpstreamModel, allowed_models: set[str] | None) -> bool:
if not model.supported_in_api:
return False
Comment thread
Komzpa marked this conversation as resolved.
Comment thread
Komzpa marked this conversation as resolved.
if allowed_models is None:
return True
return model.slug in allowed_models
117 changes: 85 additions & 32 deletions app/modules/proxy/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1712,23 +1712,30 @@ async def _build_codex_models_response(api_key: ApiKeyData | None) -> Response:

if not models:
await _release_reservation(reservation)
return JSONResponse(content=CodexModelsResponse(models=[]).model_dump(mode="json"))
return JSONResponse(content=CodexModelsResponse(models=[], data=[]).model_dump(mode="json"))

entries: list[CodexModelEntry] = []
data: list[ModelListItem] = []
for slug, model in models.items():
if not model.supported_in_api:
continue
if visibility_allowed_models is None:
if not is_public_model(model, allowed_models):
continue
entries.append(_to_codex_model_entry(model))
entry = _to_codex_model_entry(model)
entries.append(entry)
if entry.visibility == "list":
data.append(_to_model_list_item(slug, model, created=_model_list_created_at(model)))
continue
entries.append(
_to_codex_model_entry(
model,
visibility="list" if slug in visibility_allowed_models else "hide",
)
entry = _to_codex_model_entry(
model,
visibility="list" if slug in visibility_allowed_models else "hide",
)
entries.append(entry)
if entry.visibility == "list":
data.append(_to_model_list_item(slug, model, created=_model_list_created_at(model)))
await _release_reservation(reservation)
return JSONResponse(content=CodexModelsResponse(models=entries).model_dump(mode="json"))
return JSONResponse(content=CodexModelsResponse(models=entries, data=data).model_dump(mode="json"))


async def _build_models_response(api_key: ApiKeyData | None) -> Response:
Expand All @@ -1746,36 +1753,27 @@ async def _build_models_response(api_key: ApiKeyData | None) -> Response:

if not models:
await _release_reservation(reservation)
return JSONResponse(content=ModelListResponse(data=[]).model_dump(mode="json"))
return JSONResponse(content=_dump_v1_models_response(ModelListResponse(data=[])))

items: list[ModelListItem] = []
for slug, model in models.items():
if not is_public_model(model, allowed_models):
continue
items.append(
ModelListItem.model_validate(
{
"id": slug,
"created": created,
"owned_by": "codex-lb",
"metadata": _to_model_metadata(model),
"api_types": ["chat_completions"],
"capabilities": _v1_model_capabilities(model),
"context_length": _v1_input_context_window(model),
"contextLength": _v1_input_context_window(model),
"max_output_tokens": _v1_max_output_tokens(model),
"maxOutputTokens": _v1_max_output_tokens(model),
"supports_reasoning": _v1_supports_reasoning(model),
"supportsReasoning": _v1_supports_reasoning(model),
"supports_images": _v1_supports_vision(model),
"supportsImages": _v1_supports_vision(model),
"supports_vision": _v1_supports_vision(model),
"supportsVision": _v1_supports_vision(model),
}
)
)
items.append(_to_model_list_item(slug, model, created=created))
await _release_reservation(reservation)
return JSONResponse(content=ModelListResponse(data=items).model_dump(mode="json"))
return JSONResponse(content=_dump_v1_models_response(ModelListResponse(data=items)))


def _dump_v1_models_response(response: ModelListResponse) -> dict[str, JsonValue]:
payload = response.model_dump(mode="json")
for item in payload["data"]:
metadata = item.get("metadata")
if not isinstance(metadata, dict):
continue
for key in ("additional_speed_tiers", "service_tiers", "default_service_tier"):
if metadata.get(key) is None:
metadata.pop(key, None)
return payload


def _allowed_models_for_api_key(api_key: ApiKeyData | None) -> set[str] | None:
Expand All @@ -1794,6 +1792,39 @@ def _canonical_model_slug(model: str) -> str:
return resolve_model_alias(model) or model


def _to_model_list_item(slug: str, model: UpstreamModel, *, created: int) -> ModelListItem:
return ModelListItem.model_validate(
{
"id": slug,
"created": created,
"owned_by": "codex-lb",
"metadata": _to_model_metadata(model),
"api_types": ["chat_completions"],
"capabilities": _v1_model_capabilities(model),
"context_length": _v1_input_context_window(model),
"contextLength": _v1_input_context_window(model),
"max_output_tokens": _v1_max_output_tokens(model),
"maxOutputTokens": _v1_max_output_tokens(model),
"supports_reasoning": _v1_supports_reasoning(model),
"supportsReasoning": _v1_supports_reasoning(model),
"supports_images": _v1_supports_vision(model),
"supportsImages": _v1_supports_vision(model),
"supports_vision": _v1_supports_vision(model),
"supportsVision": _v1_supports_vision(model),
}
)


def _model_list_created_at(model: UpstreamModel) -> int:
for key in ("created", "created_at", "createdAt"):
raw_value = model.raw.get(key)
if isinstance(raw_value, int):
return raw_value
if isinstance(raw_value, float):
return int(raw_value)
return 0


def _codex_model_visibility_allowed_models(api_key: ApiKeyData | None) -> set[str] | None:
if api_key is None or not api_key.apply_to_codex_model or not api_key.allowed_models:
return None
Expand Down Expand Up @@ -1926,9 +1957,31 @@ def _to_model_metadata(model: UpstreamModel) -> ModelMetadata:
supported_in_api=model.supported_in_api,
minimal_client_version=model.minimal_client_version,
priority=model.priority,
additional_speed_tiers=_raw_string_list(model.raw, "additional_speed_tiers"),
service_tiers=_raw_object_list(model.raw, "service_tiers"),
default_service_tier=_raw_optional_string(model.raw, "default_service_tier"),
)


def _raw_string_list(raw: Mapping[str, JsonValue], key: str) -> list[str] | None:
value = raw.get(key)
if not isinstance(value, list):
return None
return [item for item in value if isinstance(item, str)]


def _raw_object_list(raw: Mapping[str, JsonValue], key: str) -> list[dict[str, JsonValue]] | None:
value = raw.get(key)
if not isinstance(value, list):
return None
return [dict(cast(Mapping[str, JsonValue], item)) for item in value if isinstance(item, Mapping)]


def _raw_optional_string(raw: Mapping[str, JsonValue], key: str) -> str | None:
value = raw.get(key)
return value if isinstance(value, str) else None


@v1_router.post(
"/chat/completions",
response_model=ChatCompletionResult,
Expand Down
13 changes: 9 additions & 4 deletions app/modules/proxy/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,6 @@ class CodexModelEntry(BaseModel):
visibility: str = "list"


class CodexModelsResponse(BaseModel):
models: list[CodexModelEntry]


class ModelMetadata(BaseModel):
model_config = ConfigDict(extra="forbid")

Expand All @@ -180,6 +176,9 @@ class ModelMetadata(BaseModel):
supported_in_api: bool = True
minimal_client_version: str | None = None
priority: int = 0
additional_speed_tiers: list[str] | None = None
service_tiers: list[dict[str, JsonValue]] | None = None
default_service_tier: str | None = None


class ModelListItem(BaseModel):
Expand All @@ -203,6 +202,12 @@ class ModelListResponse(BaseModel):
data: list[ModelListItem]


class CodexModelsResponse(BaseModel):
models: list[CodexModelEntry]
object: str = "list"
data: list[ModelListItem] = []


class V1UsageLimitResponse(BaseModel):
model_config = ConfigDict(extra="forbid")

Expand Down
21 changes: 21 additions & 0 deletions openspec/changes/add-codex-models-data-alias/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
## Why

Some OpenAI-compatible clients, including JetBrains IDE provider setup, probe the configured base URL by requesting `GET /backend-api/codex/models` and deserializing an OpenAI-style model list with a top-level `data` field. codex-lb's Codex-native endpoint only returned `models`, so those clients rejected the otherwise valid response before they could use the provider.

## What Changes

- Preserve the Codex-native `models` payload for `/backend-api/codex/models`.
- Add an OpenAI-compatible top-level `object: "list"` and `data` model list alias to the same response.
- Keep `data` limited to entries whose Codex visibility is `list`, so hidden Codex catalog entries remain hidden from generic OpenAI-style clients.

## Capabilities

### Modified Capabilities

- `model-catalog-compat`: `/backend-api/codex/models` remains Codex-native while also exposing an OpenAI-style `data` alias for generic client probes.

## Impact

- Code: `app/modules/proxy/api.py`, `app/modules/proxy/schemas.py`
- Tests: `tests/integration/test_v1_models.py`
- Compatibility: JetBrains/OpenAI-style model-list deserializers can read `/backend-api/codex/models` without losing Codex-native clients.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## MODIFIED Requirements

### Requirement: Native Codex model catalog stays backend-faithful

When serving `GET /backend-api/codex/models`, the system MUST keep Codex-native model catalog semantics unchanged: the top-level `context_window` field remains the backend compact/input budget unless an explicit operator override applies, and upstream raw fields such as `max_context_window` remain available when upstream provides them. The `/v1/models` compatibility metadata MUST NOT mutate the native Codex endpoint.

#### Scenario: Codex model catalog also exposes OpenAI data alias

- **WHEN** a client requests `GET /backend-api/codex/models`
- **THEN** the response keeps the Codex-native `models` list
- **AND** the response includes `object: "list"` and an OpenAI-compatible `data` list
- **AND** `data` contains model entries whose Codex visibility is `list`
- **AND** `data` excludes entries whose Codex visibility is `hide`
16 changes: 16 additions & 0 deletions openspec/changes/add-codex-models-data-alias/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
## 1. Spec And Regression Coverage

- [x] 1.1 Add an OpenSpec delta for the `/backend-api/codex/models` `data` alias.
- [x] 1.2 Add integration coverage proving the Codex-native `models` payload remains present.
- [x] 1.3 Add integration coverage proving `data` is OpenAI-style and excludes Codex-hidden entries.

## 2. Implementation

- [x] 2.1 Add `data` to the Codex models response schema.
- [x] 2.2 Reuse the `/v1/models` model-list item mapping for the compatibility alias.
- [x] 2.3 Preserve existing Codex-native `models` behavior.

## 3. Verification

- [x] 3.1 Run targeted model endpoint tests.
- [x] 3.2 Run OpenSpec validation for this change.
14 changes: 14 additions & 0 deletions openspec/changes/expose-v1-model-speed-tiers/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## Why

OpenAI-compatible model discovery clients need to know when Codex models expose upstream speed tiers, such as GPT-5.5 Fast. The Codex-native `/backend-api/codex/models` endpoint already preserves these upstream fields, but `/v1/models` drops them from `metadata`.

## What Changes

- Preserve upstream speed-tier metadata on `/v1/models` metadata entries.
- Include `additional_speed_tiers`, `service_tiers`, and `default_service_tier` when upstream provides them.
- Keep existing model IDs and pricing/request behavior unchanged.

## Impact

- OpenAI-compatible clients can synthesize fast-mode model aliases from `/v1/models` metadata.
- No database migration or dashboard UI change.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## ADDED Requirements

### Requirement: OpenAI-compatible model metadata preserves speed tiers

When serving `GET /v1/models`, the system SHALL preserve upstream speed-tier metadata in each model's `metadata` object when upstream provides it. This includes `additional_speed_tiers`, `service_tiers`, and `default_service_tier`. The system MUST NOT invent speed tiers for models whose upstream catalog entry does not advertise them.

#### Scenario: /v1/models exposes upstream fast tier metadata

- **WHEN** the upstream model catalog contains `gpt-5.5` with `additional_speed_tiers=["fast"]`
- **AND** the upstream model catalog includes a `service_tiers` entry with `id="priority"` and `name="Fast"`
- **WHEN** a client calls `GET /v1/models`
- **THEN** the `gpt-5.5` entry's metadata includes `additional_speed_tiers=["fast"]`
- **AND** the metadata includes the upstream `service_tiers` entry
- **AND** the metadata includes the upstream `default_service_tier` when present
5 changes: 5 additions & 0 deletions openspec/changes/expose-v1-model-speed-tiers/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
## Tasks

- [x] Add `/v1/models` metadata fields for upstream speed tiers.
- [x] Map fields from the refreshed model registry raw upstream payload.
- [x] Add integration coverage for speed-tier metadata on `/v1/models`.
7 changes: 7 additions & 0 deletions openspec/specs/api-keys/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,13 @@ This predicate SHALL be applied consistently across `/api/models`, `/v1/models`,
- **WHEN** a model is in the `allowed_models` set but has `supported_in_api=false`
- **THEN** that model is not exposed in any model list endpoint

#### Scenario: gpt-5.3-codex aliases share availability gate consistently

- **WHEN** `gpt-5.3-codex` has `supported_in_api=false`
- **AND** `gpt-5.3-codex-spark` has `supported_in_api=true`
- **THEN** `/api/models`, `/v1/models`, and `/backend-api/codex/models`
expose `gpt-5.3-codex-spark` but do not expose `gpt-5.3-codex`

#### Scenario: Consistent model set across endpoints

- **GIVEN** any model registry state
Expand Down
4 changes: 2 additions & 2 deletions tests/integration/test_api_keys_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2356,7 +2356,7 @@ async def fake_sqlite_writer_section():


@pytest.mark.asyncio
async def test_allowed_but_unsupported_model_is_exposed(async_client):
async def test_allowed_but_unsupported_model_is_not_exposed(async_client):
registry = get_model_registry()
models = [
_make_upstream_model(_TEST_MODELS[0], supported_in_api=True),
Expand Down Expand Up @@ -2389,7 +2389,7 @@ async def test_allowed_but_unsupported_model_is_exposed(async_client):
assert listed.status_code == 200
ids = {item["id"] for item in listed.json()["data"]}
assert _TEST_MODELS[0] in ids
assert _HIDDEN_MODEL in ids
assert _HIDDEN_MODEL not in ids


# ---------------------------------------------------------------------------
Expand Down
18 changes: 18 additions & 0 deletions tests/integration/test_path_rewrite_alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,24 @@ async def test_backend_api_codex_v1_models_aliases_canonical(async_client) -> No
assert canonical.json() == aliased.json()


@pytest.mark.asyncio
async def test_backend_api_codex_v1_models_alias_is_stable_across_second_boundary(
async_client,
monkeypatch: pytest.MonkeyPatch,
) -> None:
times = iter([1_700_000_000.0, 1_700_000_001.0])
monkeypatch.setattr(
"app.modules.proxy.api.time.time",
lambda: next(times, 1_700_000_001.0),
)

canonical = await async_client.get("/backend-api/codex/models")
aliased = await async_client.get("/backend-api/codex/v1/models")

assert canonical.status_code == aliased.status_code == 200
assert canonical.json() == aliased.json()


@pytest.mark.asyncio
async def test_top_level_v1_models_is_unaffected(async_client) -> None:
"""/v1/models is the canonical OpenAI-style namespace; the alias
Expand Down
Loading
Loading