alishahryar1--free-claude-code
f8c21a48f2
## Problem The HTTP adapter owned model routing, provider execution, and runtime-facing contracts, so API handlers depended on provider implementation types. Provider preflight also discovered private request builders dynamically, obscuring the boundary that must fail before streaming begins. ## Changes | Before | After | | --- | --- | | `api/` owned model routing and shared provider execution. | `application/` owns routing and a settings-independent `ProviderExecutor`. | | API handlers accepted `BaseProvider` callbacks. | API handlers consume the narrow structural `ProviderPort`. | | `BaseProvider` discovered `_build_request_body` dynamically. | Both transport families implement explicit abstract preflight, with LM Studio composing context validation. | | Request leases, task control, and provider model metadata had adapter/provider owners. | Application-owned ports and immutable values define those cross-package contracts. | | Boundary direction was implicit. | Architecture contracts and documentation enforce the final dependency direction. | | Package version was `3.4.19`. | Package version is `3.4.20`, with the lockfile updated. | | Coverage followed the old module layout. | Deterministic boundary/preflight regressions and live Messages/Responses smokes cover the new shape. | <!-- greptile_comment --> <details open><summary><h3>Greptile Summary</h3></summary> This PR adds a typed application boundary for provider execution. The main changes are: - New `application` package for routing, execution, ports, and model metadata. - API handlers now call application-owned routing and provider execution. - Provider preflight is now explicit on the transport families. - Runtime API composition now uses a task-control port for `/stop`. - Import-boundary tests, smoke references, docs, version, and lockfile were updated. </details> <h3>Confidence Score: 5/5</h3> This looks safe to merge. No blocking issues found in the changed code. None. <details><summary><h3><a href="https://www.greptile.com/trex"><img alt="T-Rex" src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg" height="20" align="absmiddle"></a> T-Rex Logs</h3></summary> **What T-Rex did** - Ran a deterministic pytest run for the provider boundary preflight, which completed with exit code 0 and 102 tests passing in 4.77 seconds. - Launched the environment presence check as part of the preflight, which completed with exit code 0 and confirmed OPENCODE\_API\_KEY=\[REDACTED\] matched. - Attempted the live provider smoke test, which completed with exit code 0 and 2 tests skipped due to incomplete smoke configuration. <a href="https://app.greptile.com/trex/runs/14067905/artifacts"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img alt="View all artifacts" src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a> <sub><a href="https://www.greptile.com/trex"><img alt="T-Rex" src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg" height="14" align="absmiddle"></a> Ran code and verified through T-Rex</sub> </details> <details open><summary><h3>Important Files Changed</h3></summary> | Filename | Overview | |----------|----------| | src/free_claude_code/application/execution.py | Moves shared provider execution into the application layer and keeps eager preflight before token counting and streaming. | | src/free_claude_code/application/ports.py | Adds structural provider, request-runtime, and task-control protocols used across the new boundary. | | src/free_claude_code/api/routes.py | Updates route composition to use the application provider resolver and task-control stop path. | | src/free_claude_code/providers/base.py | Makes provider preflight explicit by requiring subclasses or transport bases to implement it. | | src/free_claude_code/providers/transports/openai_chat/transport.py | Adds OpenAI-chat preflight through the same request-body builder used by streaming. | | src/free_claude_code/providers/transports/anthropic_messages/transport.py | Adds native Messages preflight through the native request-body builder. | | src/free_claude_code/providers/model_listing.py | Keeps provider model-list parsing while moving `ProviderModelInfo` ownership to the application layer. | | src/free_claude_code/runtime/bootstrap.py | Passes the runtime object through the new `tasks` service slot. | | tests/contracts/test_import_boundaries.py | Extends import-boundary tests for the new application package. | </details> <details open><summary><h3>Flowchart</h3></summary> <a href="#gh-light-mode-only"> ```mermaid %%{init: {'theme': 'neutral'}}%% flowchart LR API[api handlers and routes] --> Routing[application.routing] API --> Executor[application.execution] API --> Ports[application.ports] Executor --> ProviderPort[ProviderPort] ProviderPort --> Preflight[preflight_stream] ProviderPort --> Stream[stream_response] Runtime[runtime bootstrap and provider manager] --> Ports Providers[providers] --> Metadata[application.model_metadata] Executor --> Core[core anthropic and trace] Routing --> Config[config settings and model refs] ``` </a> <a href="#gh-dark-mode-only"> ```mermaid %%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart LR API[api handlers and routes] --> Routing[application.routing] API --> Executor[application.execution] API --> Ports[application.ports] Executor --> ProviderPort[ProviderPort] ProviderPort --> Preflight[preflight_stream] ProviderPort --> Stream[stream_response] Runtime[runtime bootstrap and provider manager] --> Ports Providers[providers] --> Metadata[application.model_metadata] Executor --> Core[core anthropic and trace] Routing --> Config[config settings and model refs] ``` </a> </details> <sub>Reviews (1): Last reviewed commit: ["Introduce typed application boundary"](https://github.com/alishahryar1/free-claude-code/commit/4cdcf97231c812c6f568ca3d74af7ce759d7f2dc) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=43462788)</sub> <!-- /greptile_comment -->
187 行
5.7 KiB
Python
187 行
5.7 KiB
Python
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from free_claude_code.api.dependencies import get_settings
|
|
from free_claude_code.api.ports import ApiServices
|
|
from free_claude_code.application.ports import StopResult
|
|
from free_claude_code.config.settings import Settings
|
|
from tests.api.support import create_test_app
|
|
|
|
app = create_test_app()
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_settings():
|
|
settings = Settings()
|
|
settings.fast_prefix_detection = True
|
|
settings.enable_network_probe_mock = True
|
|
settings.enable_title_generation_skip = True
|
|
return settings
|
|
|
|
|
|
def test_create_message_fast_prefix_detection(client, mock_settings):
|
|
app.dependency_overrides[get_settings] = lambda: mock_settings
|
|
|
|
payload = {
|
|
"model": "claude-3-sonnet",
|
|
"max_tokens": 100,
|
|
"messages": [{"role": "user", "content": "What is the prefix?"}],
|
|
}
|
|
|
|
with (
|
|
patch(
|
|
"free_claude_code.api.optimization_handlers.is_prefix_detection_request",
|
|
return_value=(True, "/ask"),
|
|
),
|
|
patch(
|
|
"free_claude_code.api.optimization_handlers.extract_command_prefix",
|
|
return_value="/ask",
|
|
),
|
|
):
|
|
response = client.post("/v1/messages", json=payload)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "/ask" in data["content"][0]["text"]
|
|
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def test_create_message_quota_check_mock(client, mock_settings):
|
|
app.dependency_overrides[get_settings] = lambda: mock_settings
|
|
|
|
payload = {
|
|
"model": "claude-3-sonnet",
|
|
"max_tokens": 100,
|
|
"messages": [{"role": "user", "content": "quota check"}],
|
|
}
|
|
|
|
with patch(
|
|
"free_claude_code.api.optimization_handlers.is_quota_check_request",
|
|
return_value=True,
|
|
):
|
|
response = client.post("/v1/messages", json=payload)
|
|
|
|
assert response.status_code == 200
|
|
assert "Quota check passed" in response.json()["content"][0]["text"]
|
|
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def test_create_message_title_generation_skip(client, mock_settings):
|
|
app.dependency_overrides[get_settings] = lambda: mock_settings
|
|
|
|
payload = {
|
|
"model": "claude-3-sonnet",
|
|
"max_tokens": 100,
|
|
"messages": [{"role": "user", "content": "generate title"}],
|
|
}
|
|
|
|
with patch(
|
|
"free_claude_code.api.optimization_handlers.is_title_generation_request",
|
|
return_value=True,
|
|
):
|
|
response = client.post("/v1/messages", json=payload)
|
|
|
|
assert response.status_code == 200
|
|
assert "Conversation" in response.json()["content"][0]["text"]
|
|
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def test_create_message_empty_messages_returns_400(client):
|
|
"""POST /v1/messages with messages: [] returns 400 invalid_request_error."""
|
|
payload = {
|
|
"model": "claude-3-sonnet",
|
|
"max_tokens": 100,
|
|
"messages": [],
|
|
}
|
|
response = client.post("/v1/messages", json=payload)
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
assert data.get("type") == "error"
|
|
assert data.get("error", {}).get("type") == "invalid_request_error"
|
|
assert "cannot be empty" in data.get("error", {}).get("message", "")
|
|
|
|
|
|
def test_count_tokens_empty_messages_returns_400(client):
|
|
"""POST /v1/messages/count_tokens with messages: [] matches messages validation."""
|
|
payload = {"model": "claude-3-sonnet", "messages": []}
|
|
response = client.post("/v1/messages/count_tokens", json=payload)
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
assert data.get("type") == "error"
|
|
assert data.get("error", {}).get("type") == "invalid_request_error"
|
|
assert "cannot be empty" in data.get("error", {}).get("message", "")
|
|
|
|
|
|
def test_count_tokens_endpoint(client):
|
|
payload = {
|
|
"model": "claude-3-sonnet",
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
}
|
|
|
|
with patch("free_claude_code.api.routes.get_token_count", return_value=5):
|
|
response = client.post("/v1/messages/count_tokens", json=payload)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["input_tokens"] == 5
|
|
|
|
|
|
def test_count_tokens_error_returns_500(client):
|
|
"""When get_token_count raises, count_tokens returns 500."""
|
|
payload = {
|
|
"model": "claude-3-sonnet",
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
}
|
|
|
|
with patch(
|
|
"free_claude_code.api.routes.get_token_count",
|
|
side_effect=RuntimeError("token error"),
|
|
):
|
|
response = client.post("/v1/messages/count_tokens", json=payload)
|
|
|
|
assert response.status_code == 500
|
|
assert "token error" in response.json()["detail"]
|
|
|
|
|
|
def test_stop_cli_with_messaging_workflow(client):
|
|
session_control = MagicMock()
|
|
session_control.stop_all = AsyncMock(return_value=StopResult(cancelled_count=3))
|
|
services = app.state.services
|
|
app.state.services = ApiServices(
|
|
requests=services.requests,
|
|
admin=services.admin,
|
|
tasks=session_control,
|
|
)
|
|
|
|
response = client.post("/stop")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["cancelled_count"] == 3
|
|
session_control.stop_all.assert_awaited_once()
|
|
|
|
|
|
def test_stop_cli_fallback_to_manager(client):
|
|
session_control = MagicMock()
|
|
session_control.stop_all = AsyncMock(return_value=StopResult(source="cli_manager"))
|
|
services = app.state.services
|
|
app.state.services = ApiServices(
|
|
requests=services.requests,
|
|
admin=services.admin,
|
|
tasks=session_control,
|
|
)
|
|
|
|
response = client.post("/stop")
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["source"] == "cli_manager"
|
|
session_control.stop_all.assert_awaited_once()
|