Skip to content
Merged
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
23 changes: 23 additions & 0 deletions coding-agent/docs/web-client-api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ Source of truth in code:
| `POST` | `/sessions/{id}/memory/semantic/rebuild` | `{ batch_size, allow_rebuild, confirm_global: true }` → `SemanticMemoryRebuildResponse` |
| `GET` | `/sessions/{id}/workspace/diff` | → `{ files[], additions, deletions }` |
| `GET` | `/sessions/{id}/workspace/patch` | → `{ format: "unified_diff", patch }` |
| `GET` | `/providers/{provider}/models` | → `ProviderModelsResponse` |
| `GET` | `/healthz` | → `{ status, sessions, version }` |

`CreateSessionRequest` (all optional unless noted):
Expand Down Expand Up @@ -83,6 +84,28 @@ context.
`topic_count`, `reviewed_memory_count`, `indexed_count`, `skipped_count`,
`deleted_count`, `indexed_ids[]`, `deleted_ids[]`).

## Provider model listing

`GET /providers/{provider}/models` returns the live model list for a provider,
for populating a model picker in the client. `provider` must be one of the
`ProviderName` literals (`openai`, `anthropic`, `copilot`, `kimi`, `kimi-code`,
`kimi-code-anthropic`, `deepseek`, `stepfun`, `codex`); anything else is a
`422`.

`ProviderModelsResponse`: `{ provider, models: [{ id }], source }` where
`source` is `"live"` when the provider's `GET /models` listing succeeded and
`"unavailable"` otherwise. The endpoint **never fails on provider-side
problems** — missing API key, network error, listing timeout (10s), or a
provider without an OpenAI-compatible listing API (anthropic, codex,
kimi-code-anthropic, copilot) all return `200` with
`{ models: [], source: "unavailable" }`. Clients should treat `"unavailable"`
as "fall back to local presets", not as an error.

Listing reuses the same provider construction as session runs
(`LLMProviderPlugin.provide_llm`), including env-var API-key fallbacks
(`KIMI_CODE_API_KEY`, `MOONSHOT_API_KEY`, `DEEPSEEK_API_KEY`, `STEP_API_KEY`),
so a provider that works for sessions also works here.

## Run metadata context pack

`GET /sessions/{id}/runs` returns `RuntimeRun[]`; each run's `metadata` dict may
Expand Down
5 changes: 5 additions & 0 deletions coding-agent/src/coding_agent/providers/openai_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,11 @@ async def stream(
if last_exception:
raise last_exception

async def list_models(self) -> list[str]:
"""Return model ids from the provider's ``GET /models`` endpoint."""
page = await self._client.models.list()
return [model.id for model in page.data]

async def close(self) -> None:
if self._http_client:
await self._http_client.aclose()
Expand Down
38 changes: 38 additions & 0 deletions coding-agent/src/coding_agent/server/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
workspace_patch_from_config,
workspace_provider_capabilities_from_config,
)
from coding_agent.core.config import ProviderName
from coding_agent.executors.external import ExecutorRunRecord, PGExecutorRunStore
from coding_agent.observability import (
prometheus_metrics_text,
Expand Down Expand Up @@ -206,6 +207,7 @@
RuntimeTurnSessionState,
run_target_from_dict,
)
from coding_agent.server.provider_models import list_provider_models
from coding_agent.server.rate_limit import RateLimits, limiter
from coding_agent.server.schemas import (
ApprovalResponseSchema,
Expand All @@ -226,6 +228,8 @@
PromptRequest,
PublishSessionRequest,
PublishSessionResponse,
ProviderModelSchema,
ProviderModelsResponse,
ReadinessResponse,
ResolveInteractionRequest,
ResumeSessionRequest,
Expand Down Expand Up @@ -2456,6 +2460,40 @@ async def readiness_check(request: Request, response: Response) -> ReadinessResp
)


@app.get("/providers/{provider}/models", response_model=ProviderModelsResponse)
@limiter.limit(RateLimits.GET_SESSION)
async def list_provider_models_endpoint(
request: Request,
provider: ProviderName,
api_key: str | None = Depends(verify_api_key),
) -> ProviderModelsResponse:
"""List available models for a provider.

Never fails on provider-side errors (missing API key, network, unsupported
provider): returns ``source="unavailable"`` with an empty model list so the
client can fall back to its presets.
"""
del request, api_key
try:
model_ids = await list_provider_models(provider)
except Exception:
logger.info(
"Provider model listing unavailable provider=%s",
provider,
exc_info=True,
)
model_ids = []
if not model_ids:
return ProviderModelsResponse(
provider=provider, models=[], source="unavailable"
)
return ProviderModelsResponse(
provider=provider,
models=[ProviderModelSchema(id=model_id) for model_id in model_ids],
source="live",
)


@app.post("/sessions", response_model=SessionResponse)
@limiter.limit(RateLimits.CREATE_SESSION)
async def create_session(
Expand Down
42 changes: 42 additions & 0 deletions coding-agent/src/coding_agent/server/provider_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Live model listing for providers, used by the HTTP server."""

from __future__ import annotations

import asyncio
import logging

from coding_agent.core.config import ProviderName
from coding_agent.plugins.llm_provider import LLMProviderPlugin
from coding_agent.providers.openai_compat import OpenAICompatProvider

logger = logging.getLogger(__name__)

# Short timeout so a dead provider cannot hang the webui.
LIST_MODELS_TIMEOUT_SECONDS = 10.0


async def list_provider_models(
provider: ProviderName,
*,
timeout: float = LIST_MODELS_TIMEOUT_SECONDS,
) -> list[str]:
"""Return model ids for an OpenAI-compatible provider.

Provider construction reuses ``LLMProviderPlugin.provide_llm`` so base_url
and env-var API-key resolution stay in one place. A fresh plugin instance
is created per call, so the constructed provider (and its httpx client) is
short-lived and is closed here — the plugin-level instance cache is never
shared across requests.

Returns an empty list when the provider is not OpenAI-compatible
(anthropic, codex, kimi-code-anthropic, copilot). Raises on construction
or network failure; callers decide how to surface that.
"""
plugin = LLMProviderPlugin(provider=provider, model="", api_key="")
instance = plugin.provide_llm()
if not isinstance(instance, OpenAICompatProvider):
return []
try:
return await asyncio.wait_for(instance.list_models(), timeout=timeout)
finally:
await instance.close()
Comment on lines +35 to +42

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg 'coding-agent/(src|tests)/.*provider_models' || true

echo "== provider_models.py outline/section =="
if [ -f coding-agent/src/coding_agent/server/provider_models.py ]; then
  wc -l coding-agent/src/coding_agent/server/provider_models.py
  cat -n coding-agent/src/coding_agent/server/provider_models.py
fi

echo "== test section =="
if [ -f coding-agent/tests/ui/test_provider_models.py ]; then
  wc -l coding-agent/tests/ui/test_provider_models.py
  sed -n '120,175p' coding-agent/tests/ui/test_provider_models.py | cat -n
fi

echo "== related provider plugin/class definitions =="
rg -n "class LLMProviderPlugin|provide_llm|class OpenAICompatProvider|close\\(" coding-agent -S || true

Repository: delete-cloud/Coding-Agent-Loop

Length of output: 36514


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate llm provider and compatibility base/provider implementations =="
git ls-files 'coding-agent/src/coding_agent/plugins/llm_provider.py' 'coding-agent/src/coding_agent/providers/*.py' | xargs -r rg -n 'class .*Provider|class .*Compat|def close|provide_llm|plugin_manager'

echo "== llm_provider implementation =="
cat -n coding-agent/src/coding_agent/plugins/llm_provider.py

echo "== provider implementations with close =="
for f in coding-agent/src/coding_agent/providers/*.py; do
  if rg -q 'class .*Provider|async def close|def close' "$f"; then
    echo "--- $f ---"
    wc -l "$f"
    rg -n -C 4 'class .*Provider|async def close|def close|def __init__|http_client' "$f" | sed -n '1,220p' || true
  fi
done

Repository: delete-cloud/Coding-Agent-Loop

Length of output: 15831


Close non-compatible provider instances before returning an empty list. provide_llm() constructs instances before isinstance(instance, OpenAICompatProvider) checks them, so non-compatible providers bypass finally and keep their short-lived cleanup resources alive.

  • coding-agent/src/coding_agent/server/provider_models.py#L35-L42: return the non-compatible provider to cleanup instead of returning early, or skip construction via an explicit OpenAI-compatible provider-only path.
  • coding-agent/tests/ui/test_provider_models.py#L144-L151: replace the sentinel with a non-compatible provider/fake and assert it is closed.
📍 Affects 2 files
  • coding-agent/src/coding_agent/server/provider_models.py#L35-L42 (this comment)
  • coding-agent/tests/ui/test_provider_models.py#L144-L151
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@coding-agent/src/coding_agent/server/provider_models.py` around lines 35 -
42, Ensure provider instances created by provide_llm() are closed even when they
are not OpenAICompatProvider: restructure the flow around the isinstance check
so cleanup runs before returning an empty list, while preserving the existing
compatible-provider timeout and close behavior. Update
coding-agent/src/coding_agent/server/provider_models.py lines 35-42 accordingly;
update coding-agent/tests/ui/test_provider_models.py lines 144-151 to use a
non-compatible fake provider and assert that it is closed.

14 changes: 14 additions & 0 deletions coding-agent/src/coding_agent/server/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,20 @@ class ReadinessResponse(BaseModel):
checks: dict[str, str]


class ProviderModelSchema(BaseModel):
"""One model entry from a provider's live model listing."""

id: str


class ProviderModelsResponse(BaseModel):
"""Response schema for the provider model-listing endpoint."""

provider: ProviderName
models: list[ProviderModelSchema]
source: Literal["live", "unavailable"]


class WorkspaceArchiveResponse(BaseModel):
format: Literal["tar.gz"]
archive_base64: str
Expand Down
161 changes: 161 additions & 0 deletions coding-agent/tests/ui/test_provider_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""Tests for the GET /providers/{provider}/models endpoint and its helper."""

from __future__ import annotations

import asyncio
from unittest.mock import patch

import pytest
from httpx import ASGITransport, AsyncClient

import coding_agent.server.http_server as http_server
from coding_agent.core.config import settings
from coding_agent.plugins.llm_provider import LLMProviderPlugin
from coding_agent.providers.openai_compat import OpenAICompatProvider
from coding_agent.server.http_server import app
from coding_agent.server.provider_models import list_provider_models
from coding_agent.server.rate_limit import limiter


@pytest.fixture
async def client():
"""Create async test client with a fresh rate limiter."""
limiter.reset()
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac


class TestProviderModelsEndpoint:
"""Endpoint-level tests with the provider client mocked out."""

async def test_live_listing_returns_model_ids(self, client, monkeypatch):
async def fake_list_provider_models(provider, *, timeout=10.0):
assert provider == "kimi-code"
return ["kimi-for-coding", "kimi-for-coding-highspeed", "k3", "k3-256k"]

monkeypatch.setattr(
http_server, "list_provider_models", fake_list_provider_models
)

response = await client.get("/providers/kimi-code/models")

assert response.status_code == 200
assert response.json() == {
"provider": "kimi-code",
"models": [
{"id": "kimi-for-coding"},
{"id": "kimi-for-coding-highspeed"},
{"id": "k3"},
{"id": "k3-256k"},
],
"source": "live",
}

async def test_unknown_provider_returns_422(self, client):
response = await client.get("/providers/not-a-provider/models")

assert response.status_code == 422

async def test_listing_failure_returns_unavailable(self, client, monkeypatch):
async def failing_list_provider_models(provider, *, timeout=10.0):
raise RuntimeError("no API key configured")

monkeypatch.setattr(
http_server, "list_provider_models", failing_list_provider_models
)

response = await client.get("/providers/deepseek/models")

assert response.status_code == 200
assert response.json() == {
"provider": "deepseek",
"models": [],
"source": "unavailable",
}

async def test_empty_listing_returns_unavailable(self, client, monkeypatch):
async def empty_list_provider_models(provider, *, timeout=10.0):
return []

monkeypatch.setattr(
http_server, "list_provider_models", empty_list_provider_models
)

response = await client.get("/providers/anthropic/models")

assert response.status_code == 200
assert response.json() == {
"provider": "anthropic",
"models": [],
"source": "unavailable",
}

async def test_requires_api_key_when_configured(self, monkeypatch):
async def fake_list_provider_models(provider, *, timeout=10.0):
return ["k3"]

monkeypatch.setattr(
http_server, "list_provider_models", fake_list_provider_models
)
limiter.reset()
with patch.object(settings, "http_api_key", "test-secret-key"):
transport = ASGITransport(app=app)
async with AsyncClient(
transport=transport, base_url="http://test"
) as authed_client:
unauthenticated = await authed_client.get("/providers/kimi-code/models")
authenticated = await authed_client.get(
"/providers/kimi-code/models",
headers={"X-API-Key": "test-secret-key"},
)

assert unauthenticated.status_code == 401
assert authenticated.status_code == 200
assert authenticated.json()["source"] == "live"


class TestListProviderModelsHelper:
"""Helper-level tests: provider reuse, filtering, timeout, cleanup."""

async def test_openai_compatible_provider_returns_ids_and_closes_client(
self, monkeypatch
):
captured: dict[str, OpenAICompatProvider] = {}

real_init = OpenAICompatProvider.__init__

def tracking_init(self, *args, **kwargs):
real_init(self, *args, **kwargs)
captured["instance"] = self

async def fake_list_models(self):
return ["kimi-for-coding", "k3"]

monkeypatch.setattr(OpenAICompatProvider, "__init__", tracking_init)
monkeypatch.setattr(OpenAICompatProvider, "list_models", fake_list_models)

ids = await list_provider_models("kimi-code")

assert ids == ["kimi-for-coding", "k3"]
# The short-lived dedicated client is closed after the call.
assert captured["instance"]._http_client is None

async def test_non_openai_compatible_provider_returns_empty(self, monkeypatch):
sentinel = object()

monkeypatch.setattr(
LLMProviderPlugin, "provide_llm", lambda self, **kwargs: sentinel
)

assert await list_provider_models("anthropic") == []

async def test_listing_hits_timeout(self, monkeypatch):
async def slow_list_models(self):
await asyncio.sleep(5)
return ["never"]

monkeypatch.setattr(OpenAICompatProvider, "list_models", slow_list_models)

with pytest.raises(asyncio.TimeoutError):
await list_provider_models("openai", timeout=0.05)
Loading
Loading