refactor: remove OpenRouter rollback, shims, and redundant layers

- OpenRouter: native Anthropic only; remove chat_request and OPENROUTER_TRANSPORT
- Drop OpenAICompatibleProvider alias, api.request_utils, voice_pipeline facade
- Simplify OpenRouter SSE, generic reasoning in conversion, messaging dispatch
- Shared markdown table helpers; API optimization response helper; contract guards
- Restore PLAN.md; update docs and tests
This commit is contained in:
Alishahryar1 2026-04-24 21:08:38 -07:00
parent 22837720ca
commit 0e3b2c24b4
43 changed files with 356 additions and 615 deletions

View file

@ -4,8 +4,6 @@ NVIDIA_NIM_API_KEY=""
# OpenRouter Config
OPENROUTER_API_KEY=""
# Diagnostic rollback only: "anthropic" (native /messages, default) | "openai" (chat completions)
OPENROUTER_TRANSPORT="anthropic"
# DeepSeek Config

View file

@ -23,7 +23,7 @@
## ARCHITECTURE PRINCIPLES (see PLAN.md)
- **Shared utilities**: Extract common logic into shared packages (e.g. `providers/common/`). Do not have one provider import from another provider's utils.
- **Shared utilities**: Put shared Anthropic protocol logic in neutral `core/anthropic/` modules. Do not have one provider import from another provider's utils.
- **DRY**: Extract shared base classes to eliminate duplication. Prefer composition over copy-paste.
- **Encapsulation**: Use accessor methods for internal state (e.g. `set_current_task()`), not direct `_attribute` assignment from outside.
- **Provider-specific config**: Keep provider-specific fields (e.g. `nim_settings`) in provider constructors, not in the base `ProviderConfig`.

87
PLAN.md Normal file
View file

@ -0,0 +1,87 @@
# Architecture Plan
This document is the baseline architecture guide referenced by `AGENTS.md`. It
records the intended dependency direction and the migration target for keeping
the project modular as providers, clients, and smoke tests grow.
## Current Product Shape
`free-claude-code` is an Anthropic-compatible proxy with optional messaging
workers:
- `api/` owns the HTTP routes, request orchestration, model routing, auth, and
server lifecycle.
- `providers/` owns upstream model adapters, request conversion, stream
conversion, provider rate limiting, and provider error mapping.
- `messaging/` owns Discord and Telegram adapters, command handling, tree
threading, session persistence, transcript rendering, and voice intake.
- `cli/` owns package entrypoints and managed Claude CLI subprocess sessions.
- `config/` owns environment-backed settings and logging setup.
- `smoke/` owns opt-in product smoke scenarios and the public coverage
inventory used by contract tests.
## Intended Dependency Direction
The repo should preserve this dependency order:
```mermaid
flowchart TD
config[config] --> api[api]
config --> providers[providers]
config --> messaging[messaging]
core[core.anthropic] --> api
core --> providers
core --> messaging
providers --> api
cli --> messaging
messaging --> api
```
The practical rule is simpler than the graph: shared protocol helpers belong in
neutral core modules, not under a provider package. Provider adapters may depend
on the neutral protocol layer, but API and messaging code should not import
provider internals.
## Target Boundaries
- `core/anthropic/`: Anthropic protocol helpers, stream primitives, content
extraction, token estimation, user-facing error strings, request conversion,
thinking, and tool helpers shared across API, providers, messaging, and tests.
- `api/runtime.py`: application composition, optional messaging startup,
session store restoration, and cleanup ownership.
- `providers/`: provider descriptors, credential resolution, transport
factories, scoped rate limiters, upstream request builders, and stream
transformers.
- `messaging/`: platform-neutral orchestration split from command dispatch,
rendering, voice handling, and persistence.
- `cli/`: typed Claude CLI runner config, subprocess management, and packaged
user-facing entrypoints.
## Smoke Coverage Policy
Default CI stays deterministic and runs `uv run pytest` against `tests/`.
Product smoke lives under `smoke/` and is enabled with `FCC_LIVE_SMOKE=1`.
Smoke runs should use `-n 0` unless a scenario is explicitly known to be safe
under xdist.
Live smoke has two valid skip classes:
- `missing_env`: credentials, local services, binaries, or explicit opt-in flags
are absent.
- `upstream_unavailable`: real providers, bot APIs, or local model servers are
unreachable.
`product_failure` and `harness_bug` are regressions. When a provider is
explicitly selected by `FCC_SMOKE_PROVIDER_MATRIX`, missing configuration should
fail instead of being silently skipped.
## Refactor Rules
- Keep public request/response shapes stable while moving internals.
- Complete module migrations in one change: update imports to the new owner and
remove old compatibility shims unless preserving a published interface is
explicitly required.
- Lock behavior with focused tests before moving shared protocol or runtime
code.
- Run checks in this order: `uv run ruff format`, `uv run ruff check`,
`uv run ty check`, `uv run pytest`.

View file

@ -509,7 +509,6 @@ Configure via `WHISPER_DEVICE` (`cpu` | `cuda` | `nvidia_nim`) and `WHISPER_MODE
| `NVIDIA_NIM_API_KEY` | NVIDIA API key | required for NIM |
| `ENABLE_THINKING` | Global switch for provider reasoning requests and Claude thinking blocks. Set `false` to hide thinking across all providers. | `true` |
| `OPENROUTER_API_KEY` | OpenRouter API key | required for OpenRouter |
| `OPENROUTER_TRANSPORT` | Diagnostic rollback only: `anthropic` uses OpenRouter's native Messages API, `openai` uses chat completions | `anthropic` |
| `DEEPSEEK_API_KEY` | DeepSeek API key | required for DeepSeek |
| `LM_STUDIO_BASE_URL` | LM Studio server URL | `http://localhost:1234/v1` |
| `LLAMACPP_BASE_URL` | llama.cpp server URL | `http://localhost:8080/v1` |
@ -574,8 +573,8 @@ See [`.env.example`](.env.example) for all supported parameters.
free-claude-code/
├── server.py # Entry point
├── api/ # FastAPI routes, API service layer, model routing, request detection, optimizations
├── core/ # Shared Anthropic protocol helpers, SSE, conversion, parsers, token counting
├── providers/ # Provider registry, scoped runtime state, OpenAI chat + Anthropic messages transports
│ └── common/ # Shared utils (SSE builder, message converter, parsers, error mapping)
├── messaging/ # MessagingPlatform ABC + Discord/Telegram bots, commands, voice, session management
├── config/ # Settings, NIM config, logging
├── cli/ # CLI session and process management
@ -593,7 +592,7 @@ uv run pytest # Run tests
### Extending
**Adding an OpenAI-compatible provider** (Groq, Together AI, etc.) — extend `OpenAIChatTransport` or its backward-compatible alias `OpenAICompatibleProvider`, then add a descriptor in the provider registry:
**Adding an OpenAI-compatible provider** (Groq, Together AI, etc.) — extend `OpenAIChatTransport`, then add a descriptor in the provider registry:
```python
from providers.openai_compat import OpenAIChatTransport

View file

@ -13,7 +13,7 @@ from providers.exceptions import ProviderError
from .dependencies import cleanup_provider
from .routes import router
from .runtime import AppRuntime, warn_if_process_auth_token
from .runtime import AppRuntime
# Opt-in to future behavior for python-telegram-bot
os.environ["PTB_TIMEDELTA"] = "1"
@ -23,11 +23,6 @@ _settings = get_settings()
configure_logging(_settings.log_file)
def _warn_if_process_auth_token(settings) -> None:
"""Compatibility wrapper for tests importing the old app helper."""
warn_if_process_auth_token(settings)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager."""

View file

@ -22,6 +22,22 @@ from .models.anthropic import MessagesRequest
from .models.responses import MessagesResponse, Usage
def _text_response(
request_data: MessagesRequest,
text: str,
*,
input_tokens: int,
output_tokens: int,
) -> MessagesResponse:
return MessagesResponse(
id=f"msg_{uuid.uuid4()}",
model=request_data.model,
content=[{"type": "text", "text": text}],
stop_reason="end_turn",
usage=Usage(input_tokens=input_tokens, output_tokens=output_tokens),
)
def try_prefix_detection(
request_data: MessagesRequest, settings: Settings
) -> MessagesResponse | None:
@ -34,12 +50,11 @@ def try_prefix_detection(
return None
logger.info("Optimization: Fast prefix detection request")
return MessagesResponse(
id=f"msg_{uuid.uuid4()}",
model=request_data.model,
content=[{"type": "text", "text": extract_command_prefix(command)}],
stop_reason="end_turn",
usage=Usage(input_tokens=100, output_tokens=5),
return _text_response(
request_data,
extract_command_prefix(command),
input_tokens=100,
output_tokens=5,
)
@ -53,13 +68,11 @@ def try_quota_mock(
return None
logger.info("Optimization: Intercepted and mocked quota probe")
return MessagesResponse(
id=f"msg_{uuid.uuid4()}",
model=request_data.model,
role="assistant",
content=[{"type": "text", "text": "Quota check passed."}],
stop_reason="end_turn",
usage=Usage(input_tokens=10, output_tokens=5),
return _text_response(
request_data,
"Quota check passed.",
input_tokens=10,
output_tokens=5,
)
@ -73,13 +86,11 @@ def try_title_skip(
return None
logger.info("Optimization: Skipped title generation request")
return MessagesResponse(
id=f"msg_{uuid.uuid4()}",
model=request_data.model,
role="assistant",
content=[{"type": "text", "text": "Conversation"}],
stop_reason="end_turn",
usage=Usage(input_tokens=100, output_tokens=5),
return _text_response(
request_data,
"Conversation",
input_tokens=100,
output_tokens=5,
)
@ -93,13 +104,11 @@ def try_suggestion_skip(
return None
logger.info("Optimization: Skipped suggestion mode request")
return MessagesResponse(
id=f"msg_{uuid.uuid4()}",
model=request_data.model,
role="assistant",
content=[{"type": "text", "text": ""}],
stop_reason="end_turn",
usage=Usage(input_tokens=100, output_tokens=1),
return _text_response(
request_data,
"",
input_tokens=100,
output_tokens=1,
)
@ -116,13 +125,11 @@ def try_filepath_mock(
filepaths = extract_filepaths_from_command(cmd, output)
logger.info("Optimization: Mocked filepath extraction")
return MessagesResponse(
id=f"msg_{uuid.uuid4()}",
model=request_data.model,
role="assistant",
content=[{"type": "text", "text": filepaths}],
stop_reason="end_turn",
usage=Usage(input_tokens=100, output_tokens=10),
return _text_response(
request_data,
filepaths,
input_tokens=100,
output_tokens=10,
)

View file

@ -1,5 +0,0 @@
"""Backward-compatible token counting import for API route handlers."""
from core.anthropic import get_token_count
__all__ = ["get_token_count"]

View file

@ -4,11 +4,11 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response
from loguru import logger
from config.settings import Settings
from core.anthropic import get_token_count
from .dependencies import get_provider_for_type, get_settings, require_api_key
from .models.anthropic import MessagesRequest, TokenCountRequest
from .models.responses import ModelResponse, ModelsListResponse
from .request_utils import get_token_count
from .services import ClaudeProxyService
router = APIRouter()
@ -75,7 +75,6 @@ def _probe_response(allow: str) -> Response:
@router.post("/v1/messages")
async def create_message(
request_data: MessagesRequest,
_raw_request: Request,
service: ClaudeProxyService = Depends(get_proxy_service),
_auth=Depends(require_api_key),
):

View file

@ -12,7 +12,7 @@ from fastapi.responses import StreamingResponse
from loguru import logger
from config.settings import Settings
from core.anthropic import get_user_facing_error_message
from core.anthropic import get_token_count, get_user_facing_error_message
from providers.base import BaseProvider
from providers.exceptions import InvalidRequestError, ProviderError
@ -20,7 +20,6 @@ from .model_router import ModelRouter
from .models.anthropic import MessagesRequest, TokenCountRequest
from .models.responses import TokenCountResponse
from .optimization_handlers import try_optimizations
from .request_utils import get_token_count
TokenCounter = Callable[[list[Any], str | list[Any] | None, list[Any] | None], int]

View file

@ -4,8 +4,6 @@ NVIDIA_NIM_API_KEY=""
# OpenRouter Config
OPENROUTER_API_KEY=""
# Diagnostic rollback only: "anthropic" (native /messages, default) | "openai" (chat completions)
OPENROUTER_TRANSPORT="anthropic"
# DeepSeek Config

View file

@ -91,9 +91,6 @@ class Settings(BaseSettings):
# ==================== OpenRouter Config ====================
open_router_api_key: str = Field(default="", validation_alias="OPENROUTER_API_KEY")
openrouter_transport: str = Field(
default="anthropic", validation_alias="OPENROUTER_TRANSPORT"
)
# ==================== DeepSeek Config ====================
deepseek_api_key: str = Field(default="", validation_alias="DEEPSEEK_API_KEY")
@ -241,15 +238,6 @@ class Settings(BaseSettings):
)
return v
@field_validator("openrouter_transport")
@classmethod
def validate_openrouter_transport(cls, v: str) -> str:
if v not in ("anthropic", "openai"):
raise ValueError(
f"openrouter_transport must be 'anthropic' or 'openai', got {v!r}"
)
return v
@field_validator("messaging_platform")
@classmethod
def validate_messaging_platform(cls, v: str) -> str:

View file

@ -15,7 +15,6 @@ class AnthropicToOpenAIConverter:
messages: list[Any],
*,
include_thinking: bool = True,
include_reasoning_for_openrouter: bool = False,
include_reasoning_content: bool = False,
) -> list[dict[str, Any]]:
result = []
@ -32,7 +31,6 @@ class AnthropicToOpenAIConverter:
AnthropicToOpenAIConverter._convert_assistant_message(
content,
include_thinking=include_thinking,
include_reasoning_for_openrouter=include_reasoning_for_openrouter,
include_reasoning_content=include_reasoning_content,
)
)
@ -50,15 +48,11 @@ class AnthropicToOpenAIConverter:
content: list[Any],
*,
include_thinking: bool = True,
include_reasoning_for_openrouter: bool = False,
include_reasoning_content: bool = False,
) -> list[dict[str, Any]]:
content_parts: list[str] = []
thinking_parts: list[str] = []
tool_calls: list[dict[str, Any]] = []
emit_reasoning_content = (
include_reasoning_for_openrouter or include_reasoning_content
)
for block in content:
block_type = get_block_type(block)
@ -70,7 +64,7 @@ class AnthropicToOpenAIConverter:
continue
thinking = get_block_attr(block, "thinking", "")
content_parts.append(f"<think>\n{thinking}\n</think>")
if emit_reasoning_content:
if include_reasoning_content:
thinking_parts.append(thinking)
elif block_type == "tool_use":
tool_input = get_block_attr(block, "input", {})
@ -97,7 +91,7 @@ class AnthropicToOpenAIConverter:
}
if tool_calls:
msg["tool_calls"] = tool_calls
if emit_reasoning_content and thinking_parts:
if include_reasoning_content and thinking_parts:
msg["reasoning_content"] = "\n".join(thinking_parts)
return [msg]
@ -191,14 +185,12 @@ def build_base_request_body(
*,
default_max_tokens: int | None = None,
include_thinking: bool = True,
include_reasoning_for_openrouter: bool = False,
include_reasoning_content: bool = False,
) -> dict[str, Any]:
"""Build the common parts of an OpenAI-format request body."""
messages = AnthropicToOpenAIConverter.convert_messages(
request_data.messages,
include_thinking=include_thinking,
include_reasoning_for_openrouter=include_reasoning_for_openrouter,
include_reasoning_content=include_reasoning_content,
)

View file

@ -2,21 +2,11 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Protocol
from typing import Any
from .commands import handle_clear_command, handle_stats_command, handle_stop_command
from .models import IncomingMessage
CommandHandler = Callable[[IncomingMessage], Awaitable[None]]
class SupportsMessagingCommands(Protocol):
async def _handle_clear_command(self, incoming: IncomingMessage) -> None: ...
async def _handle_stop_command(self, incoming: IncomingMessage) -> None: ...
async def _handle_stats_command(self, incoming: IncomingMessage) -> None: ...
def parse_command_base(text: str | None) -> str:
"""Return the slash command without bot mention suffix."""
@ -31,18 +21,18 @@ def message_kind_for_command(command_base: str) -> str:
async def dispatch_command(
handler: SupportsMessagingCommands,
handler: Any,
incoming: IncomingMessage,
command_base: str,
) -> bool:
"""Dispatch a known command and return whether it was handled."""
commands: dict[str, CommandHandler] = {
"/clear": handler._handle_clear_command,
"/stop": handler._handle_stop_command,
"/stats": handler._handle_stats_command,
commands = {
"/clear": handle_clear_command,
"/stop": handle_stop_command,
"/stats": handle_stats_command,
}
command = commands.get(command_base)
if command is None:
return False
await command(incoming)
await command(handler, incoming)
return True

View file

@ -19,11 +19,6 @@ from .command_dispatcher import (
message_kind_for_command,
parse_command_base,
)
from .commands import (
handle_clear_command,
handle_stats_command,
handle_stop_command,
)
from .event_parser import parse_cli_event
from .models import IncomingMessage
from .platforms.base import MessagingPlatform, SessionManagerInterface
@ -715,15 +710,3 @@ class ClaudeMessageHandler:
trees_to_save[tree.root_id] = tree
for root_id, tree in trees_to_save.items():
self.session_store.save_tree(root_id, tree.to_dict())
async def _handle_stop_command(self, incoming: IncomingMessage) -> None:
"""Handle /stop command from messaging platform."""
await handle_stop_command(self, incoming)
async def _handle_stats_command(self, incoming: IncomingMessage) -> None:
"""Handle /stats command."""
await handle_stats_command(self, incoming)
async def _handle_clear_command(self, incoming: IncomingMessage) -> None:
"""Handle /clear command."""
await handle_clear_command(self, incoming)

View file

@ -18,7 +18,7 @@ from core.anthropic import get_user_facing_error_message
from ..models import IncomingMessage
from ..rendering.discord_markdown import format_status_discord
from ..voice_pipeline import VoiceNotePipeline
from ..voice import PendingVoiceRegistry, VoiceTranscriptionService
from .base import MessagingPlatform
AUDIO_EXTENSIONS = (".ogg", ".mp4", ".mp3", ".wav", ".m4a")
@ -116,7 +116,8 @@ class DiscordPlatform(MessagingPlatform):
self._connected = False
self._limiter: Any | None = None
self._start_task: asyncio.Task | None = None
self._voice_pipeline = VoiceNotePipeline()
self._pending_voice = PendingVoiceRegistry()
self._voice_transcription = VoiceTranscriptionService()
async def _handle_client_message(self, message: Any) -> None:
"""Adapter entry point used by the internal discord client."""
@ -126,19 +127,17 @@ class DiscordPlatform(MessagingPlatform):
self, chat_id: str, voice_msg_id: str, status_msg_id: str
) -> None:
"""Register a voice note as pending transcription."""
await self._voice_pipeline.register_pending(
chat_id, voice_msg_id, status_msg_id
)
await self._pending_voice.register(chat_id, voice_msg_id, status_msg_id)
async def cancel_pending_voice(
self, chat_id: str, reply_id: str
) -> tuple[str, str] | None:
"""Cancel a pending voice transcription. Returns (voice_msg_id, status_msg_id) if found."""
return await self._voice_pipeline.cancel_pending(chat_id, reply_id)
return await self._pending_voice.cancel(chat_id, reply_id)
async def _is_voice_still_pending(self, chat_id: str, voice_msg_id: str) -> bool:
"""Check if a voice note is still pending (not cancelled)."""
return await self._voice_pipeline.is_pending(chat_id, voice_msg_id)
return await self._pending_voice.is_pending(chat_id, voice_msg_id)
def _get_audio_attachment(self, message: Any) -> Any | None:
"""Return first audio attachment, or None."""
@ -199,7 +198,7 @@ class DiscordPlatform(MessagingPlatform):
try:
await attachment.save(str(tmp_path))
transcribed = await self._voice_pipeline.transcribe(
transcribed = await self._voice_transcription.transcribe(
tmp_path,
ct,
whisper_model=settings.whisper_model,
@ -210,7 +209,7 @@ class DiscordPlatform(MessagingPlatform):
await self.queue_delete_message(channel_id, str(status_msg_id))
return True
await self._voice_pipeline.complete(
await self._pending_voice.complete(
channel_id, message_id, str(status_msg_id)
)

View file

@ -27,7 +27,7 @@ if TYPE_CHECKING:
from ..models import IncomingMessage
from ..rendering.telegram_markdown import escape_md_v2, format_status
from ..voice_pipeline import VoiceNotePipeline
from ..voice import PendingVoiceRegistry, VoiceTranscriptionService
from .base import MessagingPlatform
# Optional import - python-telegram-bot may not be installed
@ -83,25 +83,24 @@ class TelegramPlatform(MessagingPlatform):
self._connected = False
self._limiter: Any | None = None # Will be MessagingRateLimiter
# Pending voice transcriptions: (chat_id, msg_id) -> (voice_msg_id, status_msg_id)
self._voice_pipeline = VoiceNotePipeline()
self._pending_voice = PendingVoiceRegistry()
self._voice_transcription = VoiceTranscriptionService()
async def _register_pending_voice(
self, chat_id: str, voice_msg_id: str, status_msg_id: str
) -> None:
"""Register a voice note as pending transcription (for /clear reply during transcription)."""
await self._voice_pipeline.register_pending(
chat_id, voice_msg_id, status_msg_id
)
await self._pending_voice.register(chat_id, voice_msg_id, status_msg_id)
async def cancel_pending_voice(
self, chat_id: str, reply_id: str
) -> tuple[str, str] | None:
"""Cancel a pending voice transcription. Returns (voice_msg_id, status_msg_id) if found."""
return await self._voice_pipeline.cancel_pending(chat_id, reply_id)
return await self._pending_voice.cancel(chat_id, reply_id)
async def _is_voice_still_pending(self, chat_id: str, voice_msg_id: str) -> bool:
"""Check if a voice note is still pending (not cancelled)."""
return await self._voice_pipeline.is_pending(chat_id, voice_msg_id)
return await self._pending_voice.is_pending(chat_id, voice_msg_id)
async def start(self) -> None:
"""Initialize and connect to Telegram."""
@ -598,7 +597,7 @@ class TelegramPlatform(MessagingPlatform):
tg_file = await context.bot.get_file(voice.file_id)
await tg_file.download_to_drive(custom_path=str(tmp_path))
transcribed = await self._voice_pipeline.transcribe(
transcribed = await self._voice_transcription.transcribe(
tmp_path,
voice.mime_type or "audio/ogg",
whisper_model=settings.whisper_model,
@ -609,7 +608,7 @@ class TelegramPlatform(MessagingPlatform):
await self.queue_delete_message(chat_id, str(status_msg_id))
return
await self._voice_pipeline.complete(chat_id, message_id, str(status_msg_id))
await self._pending_voice.complete(chat_id, message_id, str(status_msg_id))
incoming = IncomingMessage(
text=transcribed,

View file

@ -1,41 +1,3 @@
"""Markdown rendering utilities for messaging platforms."""
from .discord_markdown import (
discord_bold,
discord_code_inline,
escape_discord,
escape_discord_code,
format_status_discord,
render_markdown_to_discord,
)
from .discord_markdown import (
format_status as format_status_discord_fn,
)
from .telegram_markdown import (
escape_md_v2,
escape_md_v2_code,
escape_md_v2_link_url,
mdv2_bold,
mdv2_code_inline,
render_markdown_to_mdv2,
)
from .telegram_markdown import (
format_status as format_status_telegram_fn,
)
__all__ = [
"discord_bold",
"discord_code_inline",
"escape_discord",
"escape_discord_code",
"escape_md_v2",
"escape_md_v2_code",
"escape_md_v2_link_url",
"format_status_discord",
"format_status_discord_fn",
"format_status_telegram_fn",
"mdv2_bold",
"mdv2_code_inline",
"render_markdown_to_discord",
"render_markdown_to_mdv2",
]
__all__: list[str] = []

View file

@ -4,10 +4,10 @@ Discord uses standard markdown: **bold**, *italic*, `code`, ```code block```.
Used by the message handler and Discord platform adapter.
"""
import re
from markdown_it import MarkdownIt
from .markdown_tables import normalize_gfm_tables
# Discord escapes: \ * _ ` ~ | >
DISCORD_SPECIAL = set("\\*_`~|>")
@ -15,53 +15,6 @@ _MD = MarkdownIt("commonmark", {"html": False, "breaks": False})
_MD.enable("strikethrough")
_MD.enable("table")
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$")
_FENCE_RE = re.compile(r"^\s*```")
def _is_gfm_table_header_line(line: str) -> bool:
"""Check if line is a GFM table header."""
if "|" not in line:
return False
if _TABLE_SEP_RE.match(line):
return False
stripped = line.strip()
parts = [p.strip() for p in stripped.strip("|").split("|")]
parts = [p for p in parts if p != ""]
return len(parts) >= 2
def _normalize_gfm_tables(text: str) -> str:
"""Insert blank line before detected tables outside code blocks."""
lines = text.splitlines()
if len(lines) < 2:
return text
out_lines: list[str] = []
in_fence = False
for idx, line in enumerate(lines):
if _FENCE_RE.match(line):
in_fence = not in_fence
out_lines.append(line)
continue
if (
not in_fence
and idx + 1 < len(lines)
and _is_gfm_table_header_line(line)
and _TABLE_SEP_RE.match(lines[idx + 1])
and out_lines
and out_lines[-1].strip() != ""
):
m = re.match(r"^(\s*)", line)
indent = m.group(1) if m else ""
out_lines.append(indent)
out_lines.append(line)
return "\n".join(out_lines)
def escape_discord(text: str) -> str:
"""Escape text for Discord markdown (bold, italic, etc.)."""
@ -104,7 +57,7 @@ def render_markdown_to_discord(text: str) -> str:
if not text:
return ""
text = _normalize_gfm_tables(text)
text = normalize_gfm_tables(text)
tokens = _MD.parse(text)
def render_inline_table_plain(children) -> str:

View file

@ -0,0 +1,49 @@
"""Shared Markdown table pre-normalization for platform renderers."""
from __future__ import annotations
import re
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$")
_FENCE_RE = re.compile(r"^\s*```")
def _is_gfm_table_header_line(line: str) -> bool:
"""Return whether a line looks like a GFM table header."""
if "|" not in line:
return False
if _TABLE_SEP_RE.match(line):
return False
parts = [part.strip() for part in line.strip().strip("|").split("|")]
return len([part for part in parts if part]) >= 2
def normalize_gfm_tables(text: str) -> str:
"""Insert blank lines before detected tables outside fenced code blocks."""
lines = text.splitlines()
if len(lines) < 2:
return text
out_lines: list[str] = []
in_fence = False
for idx, line in enumerate(lines):
if _FENCE_RE.match(line):
in_fence = not in_fence
out_lines.append(line)
continue
if (
not in_fence
and idx + 1 < len(lines)
and _is_gfm_table_header_line(line)
and _TABLE_SEP_RE.match(lines[idx + 1])
and out_lines
and out_lines[-1].strip() != ""
):
indent_match = re.match(r"^(\s*)", line)
out_lines.append(indent_match.group(1) if indent_match else "")
out_lines.append(line)
return "\n".join(out_lines)

View file

@ -4,10 +4,10 @@ Renders common Markdown into Telegram MarkdownV2 format.
Used by the message handler and Telegram platform adapter.
"""
import re
from markdown_it import MarkdownIt
from .markdown_tables import normalize_gfm_tables
MDV2_SPECIAL_CHARS = set("\\_*[]()~`>#+-=|{}.!")
MDV2_LINK_ESCAPE = set("\\)")
@ -15,59 +15,6 @@ _MD = MarkdownIt("commonmark", {"html": False, "breaks": False})
_MD.enable("strikethrough")
_MD.enable("table")
_TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$")
_FENCE_RE = re.compile(r"^\s*```")
def _is_gfm_table_header_line(line: str) -> bool:
"""Check if line is a GFM table header (pipe-delimited, not separator)."""
if "|" not in line:
return False
if _TABLE_SEP_RE.match(line):
return False
stripped = line.strip()
parts = [p.strip() for p in stripped.strip("|").split("|")]
parts = [p for p in parts if p != ""]
return len(parts) >= 2
def _normalize_gfm_tables(text: str) -> str:
"""
Many LLMs emit tables immediately after a paragraph line (no blank line).
Markdown-it will treat that as a softbreak within the paragraph, so the
table extension won't trigger. Insert a blank line before detected tables.
We only do this outside fenced code blocks.
"""
lines = text.splitlines()
if len(lines) < 2:
return text
out_lines: list[str] = []
in_fence = False
for idx, line in enumerate(lines):
if _FENCE_RE.match(line):
in_fence = not in_fence
out_lines.append(line)
continue
if (
not in_fence
and idx + 1 < len(lines)
and _is_gfm_table_header_line(line)
and _TABLE_SEP_RE.match(lines[idx + 1])
and out_lines
and out_lines[-1].strip() != ""
):
m = re.match(r"^(\s*)", line)
indent = m.group(1) if m else ""
out_lines.append(indent)
out_lines.append(line)
return "\n".join(out_lines)
def escape_md_v2(text: str) -> str:
"""Escape text for Telegram MarkdownV2."""
@ -107,7 +54,7 @@ def render_markdown_to_mdv2(text: str) -> str:
if not text:
return ""
text = _normalize_gfm_tables(text)
text = normalize_gfm_tables(text)
tokens = _MD.parse(text)
def render_inline_table_plain(children) -> str:

View file

@ -1,53 +0,0 @@
"""Platform-neutral voice note pipeline."""
from __future__ import annotations
from pathlib import Path
from .voice import PendingVoiceRegistry, VoiceTranscriptionService
class VoiceNotePipeline:
"""Coordinate pending voice state and transcription backend execution."""
def __init__(
self,
*,
registry: PendingVoiceRegistry | None = None,
transcription: VoiceTranscriptionService | None = None,
) -> None:
self._registry = registry or PendingVoiceRegistry()
self._transcription = transcription or VoiceTranscriptionService()
async def register_pending(
self, chat_id: str, voice_msg_id: str, status_msg_id: str
) -> None:
await self._registry.register(chat_id, voice_msg_id, status_msg_id)
async def cancel_pending(
self, chat_id: str, reply_id: str
) -> tuple[str, str] | None:
return await self._registry.cancel(chat_id, reply_id)
async def is_pending(self, chat_id: str, voice_msg_id: str) -> bool:
return await self._registry.is_pending(chat_id, voice_msg_id)
async def complete(
self, chat_id: str, voice_msg_id: str, status_msg_id: str
) -> None:
await self._registry.complete(chat_id, voice_msg_id, status_msg_id)
async def transcribe(
self,
file_path: Path,
mime_type: str,
*,
whisper_model: str,
whisper_device: str,
) -> str:
return await self._transcription.transcribe(
file_path,
mime_type,
whisper_model=whisper_model,
whisper_device=whisper_device,
)

View file

@ -14,8 +14,8 @@ from .exceptions import (
from .llamacpp import LlamaCppProvider
from .lmstudio import LMStudioProvider
from .nvidia_nim import NvidiaNimProvider
from .open_router import OpenRouterChatProvider, OpenRouterProvider
from .openai_compat import OpenAIChatTransport, OpenAICompatibleProvider
from .open_router import OpenRouterProvider
from .openai_compat import OpenAIChatTransport
__all__ = [
"APIError",
@ -28,8 +28,6 @@ __all__ = [
"LlamaCppProvider",
"NvidiaNimProvider",
"OpenAIChatTransport",
"OpenAICompatibleProvider",
"OpenRouterChatProvider",
"OpenRouterProvider",
"OverloadedError",
"ProviderConfig",

View file

@ -3,14 +3,14 @@
from typing import Any
from providers.base import ProviderConfig
from providers.openai_compat import OpenAICompatibleProvider
from providers.openai_compat import OpenAIChatTransport
from .request import build_request_body
DEEPSEEK_BASE_URL = "https://api.deepseek.com"
class DeepSeekProvider(OpenAICompatibleProvider):
class DeepSeekProvider(OpenAIChatTransport):
"""DeepSeek provider using OpenAI-compatible chat completions."""
def __init__(self, config: ProviderConfig):

View file

@ -2,7 +2,6 @@
from providers.anthropic_messages import AnthropicMessagesTransport
from providers.base import ProviderConfig
from providers.rate_limit import GlobalRateLimiter as GlobalRateLimiter
LLAMACPP_DEFAULT_BASE_URL = "http://localhost:8080/v1"

View file

@ -2,7 +2,6 @@
from providers.anthropic_messages import AnthropicMessagesTransport
from providers.base import ProviderConfig
from providers.rate_limit import GlobalRateLimiter as GlobalRateLimiter
LMSTUDIO_DEFAULT_BASE_URL = "http://localhost:1234/v1"

View file

@ -8,7 +8,7 @@ from loguru import logger
from config.nim import NimSettings
from providers.base import ProviderConfig
from providers.openai_compat import OpenAICompatibleProvider
from providers.openai_compat import OpenAIChatTransport
from .request import (
build_request_body,
@ -19,7 +19,7 @@ from .request import (
NVIDIA_NIM_BASE_URL = "https://integrate.api.nvidia.com/v1"
class NvidiaNimProvider(OpenAICompatibleProvider):
class NvidiaNimProvider(OpenAIChatTransport):
"""NVIDIA NIM provider using official OpenAI client."""
def __init__(self, config: ProviderConfig, *, nim_settings: NimSettings):

View file

@ -1,5 +1,5 @@
"""OpenRouter provider - Anthropic-compatible and rollback transports."""
"""OpenRouter provider - Anthropic-compatible native transport."""
from .client import OPENROUTER_BASE_URL, OpenRouterChatProvider, OpenRouterProvider
from .client import OPENROUTER_BASE_URL, OpenRouterProvider
__all__ = ["OPENROUTER_BASE_URL", "OpenRouterChatProvider", "OpenRouterProvider"]
__all__ = ["OPENROUTER_BASE_URL", "OpenRouterProvider"]

View file

@ -1,43 +0,0 @@
"""OpenAI chat-completions request builder for OpenRouter rollback mode."""
from typing import Any
from loguru import logger
from core.anthropic import build_base_request_body
OPENROUTER_DEFAULT_MAX_TOKENS = 81920
def build_chat_request_body(request_data: Any, *, thinking_enabled: bool) -> dict:
"""Build OpenAI-format request body from Anthropic request for OpenRouter."""
logger.debug(
"OPENROUTER_CHAT_REQUEST: conversion start model={} msgs={}",
getattr(request_data, "model", "?"),
len(getattr(request_data, "messages", [])),
)
body = build_base_request_body(
request_data,
include_thinking=thinking_enabled,
default_max_tokens=OPENROUTER_DEFAULT_MAX_TOKENS,
include_reasoning_for_openrouter=thinking_enabled,
)
extra_body: dict[str, Any] = {}
request_extra = getattr(request_data, "extra_body", None)
if request_extra:
extra_body.update(request_extra)
if thinking_enabled:
extra_body.setdefault("reasoning", {"enabled": True})
if extra_body:
body["extra_body"] = extra_body
logger.debug(
"OPENROUTER_CHAT_REQUEST: conversion done model={} msgs={} tools={}",
body.get("model"),
len(body.get("messages", [])),
len(body.get("tools", [])),
)
return body

View file

@ -11,9 +11,7 @@ from typing import Any
from core.anthropic import SSEBuilder, append_request_id
from providers.anthropic_messages import AnthropicMessagesTransport, StreamChunkMode
from providers.base import ProviderConfig
from providers.openai_compat import OpenAIChatTransport
from .chat_request import build_chat_request_body
from .request import build_request_body
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
@ -32,39 +30,6 @@ class _SSEFilterState:
message_stopped: bool = False
class OpenRouterChatProvider(OpenAIChatTransport):
"""OpenRouter provider using OpenAI-compatible chat completions."""
def __init__(self, config: ProviderConfig):
super().__init__(
config,
provider_name="OPENROUTER",
base_url=config.base_url or OPENROUTER_BASE_URL,
api_key=config.api_key,
)
def _build_request_body(self, request: Any) -> dict:
"""Build OpenAI chat-completions request body."""
return build_chat_request_body(
request,
thinking_enabled=self._is_thinking_enabled(request),
)
def _handle_extra_reasoning(
self, delta: Any, sse: SSEBuilder, *, thinking_enabled: bool
) -> Iterator[str]:
"""Handle reasoning_details for StepFun models in rollback mode."""
if not thinking_enabled:
return
reasoning_details = getattr(delta, "reasoning_details", None)
if reasoning_details and isinstance(reasoning_details, list):
for item in reasoning_details:
text = item.get("text", "") if isinstance(item, dict) else ""
if text:
yield from sse.ensure_thinking_block()
yield sse.emit_thinking_delta(text)
class OpenRouterProvider(AnthropicMessagesTransport):
"""OpenRouter provider using the native Anthropic-compatible messages API."""
@ -157,69 +122,22 @@ class OpenRouterProvider(AnthropicMessagesTransport):
state.open_block_types.pop(open_upstream_index, None)
return "".join(events)
def _filter_sse_event(self, event: str, state: _SSEFilterState) -> str | None:
"""Drop upstream thinking blocks and remap remaining block indexes."""
event_name, data_text = self._parse_sse_event(event)
if not event_name or not data_text:
return event
@staticmethod
def _should_drop_block_type(block_type: Any, *, thinking_enabled: bool) -> bool:
if not isinstance(block_type, str):
return False
if block_type.startswith("redacted_thinking"):
return True
return not thinking_enabled and "thinking" in block_type
try:
payload = json.loads(data_text)
except json.JSONDecodeError:
return event
if event_name == "content_block_start":
block = payload.get("content_block")
block_type = block.get("type") if isinstance(block, dict) else None
upstream_index = payload.get("index")
if isinstance(block_type, str) and "thinking" in block_type:
if isinstance(upstream_index, int):
state.dropped_indexes.add(upstream_index)
return None
mapped_index = self._remap_index(payload, state, create=True)
if mapped_index is not None:
payload["index"] = mapped_index
if isinstance(upstream_index, int) and isinstance(block_type, str):
prefix = self._close_open_blocks_before(state, upstream_index)
state.open_block_types[upstream_index] = block_type
return prefix + self._format_sse_event(
event_name, json.dumps(payload)
)
return self._format_sse_event(event_name, json.dumps(payload))
if event_name == "content_block_delta":
delta = payload.get("delta")
delta_type = delta.get("type") if isinstance(delta, dict) else None
if isinstance(delta_type, str) and "thinking" in delta_type:
return None
mapped_index = self._remap_index(payload, state, create=False)
if mapped_index is None:
return None
payload["index"] = mapped_index
return self._format_sse_event(event_name, json.dumps(payload))
if event_name == "content_block_stop":
upstream_index = payload.get("index")
if (
isinstance(upstream_index, int)
and upstream_index in state.closed_indexes
):
state.closed_indexes.discard(upstream_index)
return None
mapped_index = self._remap_index(payload, state, create=False)
if mapped_index is None:
return None
payload["index"] = mapped_index
if isinstance(upstream_index, int):
state.open_block_types.pop(upstream_index, None)
return self._format_sse_event(event_name, json.dumps(payload))
return event
def _normalize_sse_event(self, event: str, state: _SSEFilterState) -> str | None:
"""Normalize OpenRouter-native extras into Anthropic-compatible events."""
def _transform_sse_payload(
self,
event: str,
state: _SSEFilterState,
*,
thinking_enabled: bool,
) -> str | None:
"""Normalize OpenRouter SSE events and enforce local thinking policy."""
event_name, data_text = self._parse_sse_event(event)
if not event_name or not data_text:
return event
@ -235,7 +153,9 @@ class OpenRouterProvider(AnthropicMessagesTransport):
return event
block_type = block.get("type")
upstream_index = payload.get("index")
if isinstance(block_type, str) and block_type == "redacted_thinking":
if self._should_drop_block_type(
block_type, thinking_enabled=thinking_enabled
):
if isinstance(upstream_index, int):
state.dropped_indexes.add(upstream_index)
return None
@ -250,14 +170,16 @@ class OpenRouterProvider(AnthropicMessagesTransport):
event_name, json.dumps(payload)
)
return self._format_sse_event(event_name, json.dumps(payload))
return event
return None if not thinking_enabled else event
if event_name == "content_block_delta":
delta = payload.get("delta")
if not isinstance(delta, dict):
return event
delta_type = delta.get("type")
if isinstance(delta_type, str) and delta_type == "redacted_thinking_delta":
if self._should_drop_block_type(
delta_type, thinking_enabled=thinking_enabled
):
return None
mapped_index = self._remap_index(payload, state, create=False)
@ -266,6 +188,8 @@ class OpenRouterProvider(AnthropicMessagesTransport):
return self._format_sse_event(event_name, json.dumps(payload))
if payload.get("index") in state.dropped_indexes:
return None
if not thinking_enabled:
return None
if event_name == "content_block_stop":
upstream_index = payload.get("index")
@ -283,6 +207,8 @@ class OpenRouterProvider(AnthropicMessagesTransport):
return self._format_sse_event(event_name, json.dumps(payload))
if payload.get("index") in state.dropped_indexes:
return None
if not thinking_enabled:
return None
return event
@ -309,10 +235,14 @@ class OpenRouterProvider(AnthropicMessagesTransport):
if thinking_enabled:
if isinstance(state, _SSEFilterState):
return self._normalize_sse_event(event, state)
return self._transform_sse_payload(
event, state, thinking_enabled=thinking_enabled
)
return event
if isinstance(state, _SSEFilterState):
return self._filter_sse_event(event, state)
return self._transform_sse_payload(
event, state, thinking_enabled=thinking_enabled
)
return event
def _format_error_message(self, base_message: str, request_id: str | None) -> str:

View file

@ -378,7 +378,3 @@ class OpenAIChatTransport(BaseProvider):
)
yield sse.message_delta(map_stop_reason(finish_reason), output_tokens)
yield sse.message_stop()
# Backward-compatible class name used by existing provider implementations.
OpenAICompatibleProvider = OpenAIChatTransport

View file

@ -15,7 +15,6 @@ from providers.lmstudio import LMStudioProvider
from providers.nvidia_nim import NVIDIA_NIM_BASE_URL, NvidiaNimProvider
from providers.open_router import (
OPENROUTER_BASE_URL,
OpenRouterChatProvider,
OpenRouterProvider,
)
@ -85,9 +84,7 @@ def _create_nvidia_nim(config: ProviderConfig, settings: Settings) -> BaseProvid
return NvidiaNimProvider(config, nim_settings=settings.nim)
def _create_open_router(config: ProviderConfig, settings: Settings) -> BaseProvider:
if settings.openrouter_transport == "openai":
return OpenRouterChatProvider(config)
def _create_open_router(config: ProviderConfig, _settings: Settings) -> BaseProvider:
return OpenRouterProvider(config)

View file

@ -210,7 +210,7 @@ CAPABILITY_CONTRACTS: tuple[CapabilityContract, ...] = (
"request_behavior",
"token_counting",
"count_tokens_contract",
"api.request_utils",
"core.anthropic.get_token_count",
"messages, system blocks, tools, images, thinking, tool results",
"positive input token estimate",
"500 with readable detail on unexpected failure",

View file

@ -31,30 +31,6 @@ class FeatureCoverage:
def has_pytest_coverage(self) -> bool:
return bool(self.pytest_contract_tests)
@property
def has_live_prereq_coverage(self) -> bool:
return bool(self.live_prereq_tests)
@property
def has_product_e2e_coverage(self) -> bool:
return bool(self.product_e2e_tests)
@property
def has_smoke_coverage(self) -> bool:
return self.has_product_e2e_coverage
@property
def pytest_tests(self) -> tuple[str, ...]:
return self.pytest_contract_tests
@property
def smoke_tests(self) -> tuple[str, ...]:
return self.product_e2e_tests
@property
def all_smoke_tests(self) -> tuple[str, ...]:
return self.live_prereq_tests + self.product_e2e_tests
README_FEATURES: tuple[str, ...] = (
"zero_cost_provider_access",
@ -495,26 +471,3 @@ def feature_ids(*, source: FeatureSource | None = None) -> set[str]:
for feature in FEATURE_INVENTORY
if source is None or feature.source == source
}
def product_smoke_feature_ids() -> set[str]:
"""Return feature IDs with at least one product E2E owner."""
return {
feature.feature_id
for feature in FEATURE_INVENTORY
if feature.has_product_e2e_coverage
}
def live_prereq_feature_ids() -> set[str]:
"""Return feature IDs with prerequisite/live probe owners."""
return {
feature.feature_id
for feature in FEATURE_INVENTORY
if feature.has_live_prereq_coverage
}
def smoke_feature_ids() -> set[str]:
"""Backward-compatible alias for product smoke feature IDs."""
return product_smoke_feature_ids()

View file

@ -88,7 +88,7 @@ def test_provider_error_e2e(smoke_config: SmokeConfig) -> None:
assert any(event.event == "error" for event in events) or text_content(events)
def test_openrouter_native_and_rollback_e2e(smoke_config: SmokeConfig) -> None:
def test_openrouter_native_e2e(smoke_config: SmokeConfig) -> None:
models = [
model
for model in ProviderMatrixDriver(smoke_config).configured_models()
@ -98,30 +98,28 @@ def test_openrouter_native_and_rollback_e2e(smoke_config: SmokeConfig) -> None:
pytest.skip("missing_env: open_router is not configured")
provider_model = models[0]
for transport in ("anthropic", "openai"):
with SmokeServerDriver(
smoke_config,
name=f"product-openrouter-{transport}",
env_overrides={
"MODEL": provider_model.full_model,
"MESSAGING_PLATFORM": "none",
"OPENROUTER_TRANSPORT": transport,
},
).run() as server:
turn = ConversationDriver(server, smoke_config).stream(
{
"model": "claude-opus-4-7",
"max_tokens": 256,
"messages": [
{
"role": "user",
"content": "Reply with one short sentence.",
}
],
"thinking": {"type": "adaptive", "budget_tokens": 1024},
}
)
assert_product_stream(turn.events)
with SmokeServerDriver(
smoke_config,
name="product-openrouter-native",
env_overrides={
"MODEL": provider_model.full_model,
"MESSAGING_PLATFORM": "none",
},
).run() as server:
turn = ConversationDriver(server, smoke_config).stream(
{
"model": "claude-opus-4-7",
"max_tokens": 256,
"messages": [
{
"role": "user",
"content": "Reply with one short sentence.",
}
],
"thinking": {"type": "adaptive", "budget_tokens": 1024},
}
)
assert_product_stream(turn.events)
def _run_for_each_provider(smoke_config: SmokeConfig, scenario) -> None:

View file

@ -1,28 +1,35 @@
import importlib
from types import SimpleNamespace
from typing import cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from config.settings import Settings
def test_warn_if_process_auth_token_logs_warning():
api_app_mod = importlib.import_module("api.app")
settings = SimpleNamespace(uses_process_anthropic_auth_token=lambda: True)
api_runtime_mod = importlib.import_module("api.runtime")
settings = cast(
Settings, SimpleNamespace(uses_process_anthropic_auth_token=lambda: True)
)
with patch.object(api_app_mod.logger, "warning") as warning:
api_app_mod._warn_if_process_auth_token(settings)
with patch.object(api_runtime_mod.logger, "warning") as warning:
api_runtime_mod.warn_if_process_auth_token(settings)
warning.assert_called_once()
assert "ANTHROPIC_AUTH_TOKEN" in warning.call_args.args[0]
def test_warn_if_process_auth_token_skips_explicit_dotenv_config():
api_app_mod = importlib.import_module("api.app")
settings = SimpleNamespace(uses_process_anthropic_auth_token=lambda: False)
api_runtime_mod = importlib.import_module("api.runtime")
settings = cast(
Settings, SimpleNamespace(uses_process_anthropic_auth_token=lambda: False)
)
with patch.object(api_app_mod.logger, "warning") as warning:
api_app_mod._warn_if_process_auth_token(settings)
with patch.object(api_runtime_mod.logger, "warning") as warning:
api_runtime_mod.warn_if_process_auth_token(settings)
warning.assert_not_called()

View file

@ -1,4 +1,4 @@
"""Tests for api/request_utils.py module."""
"""Tests for API request detection and token counting helpers."""
from unittest.mock import MagicMock
@ -11,7 +11,7 @@ from api.detection import (
is_title_generation_request,
)
from api.models.anthropic import Message, MessagesRequest
from api.request_utils import get_token_count
from core.anthropic import get_token_count
class TestQuotaCheckRequest:

View file

@ -25,6 +25,14 @@ def test_provider_adapters_do_not_import_runtime_layers() -> None:
assert offenders == []
def test_removed_openrouter_rollback_transport_stays_removed() -> None:
repo_root = Path(__file__).resolve().parents[2]
assert not (repo_root / "providers" / "open_router" / "chat_request.py").exists()
assert _text_occurrences(repo_root, "OpenRouter" + "ChatProvider") == []
assert _text_occurrences(repo_root, "OPENROUTER" + "_TRANSPORT") == []
def test_architecture_doc_names_enforced_boundaries() -> None:
repo_root = Path(__file__).resolve().parents[2]
text = (repo_root / "PLAN.md").read_text(encoding="utf-8")
@ -59,3 +67,34 @@ def _imports_from(path: Path) -> list[str]:
elif isinstance(node, ast.ImportFrom) and node.module:
imports.append(node.module)
return imports
def _text_occurrences(repo_root: Path, needle: str) -> list[str]:
searchable_paths = [
repo_root / "api",
repo_root / "cli",
repo_root / "config",
repo_root / "core",
repo_root / "messaging",
repo_root / "providers",
repo_root / "smoke",
repo_root / "tests",
repo_root / ".env.example",
repo_root / "AGENTS.md",
repo_root / "PLAN.md",
repo_root / "README.md",
repo_root / "pyproject.toml",
]
occurrences: list[str] = []
for root in searchable_paths:
paths = root.rglob("*") if root.is_dir() else (root,)
for path in paths:
if not path.is_file():
continue
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
if needle in text:
occurrences.append(str(path.relative_to(repo_root)))
return sorted(occurrences)

View file

@ -1,8 +1,6 @@
"""Tests for messaging/rendering/discord_markdown.py."""
from messaging.rendering.discord_markdown import (
_is_gfm_table_header_line,
_normalize_gfm_tables,
discord_bold,
discord_code_inline,
escape_discord,
@ -11,6 +9,10 @@ from messaging.rendering.discord_markdown import (
format_status_discord,
render_markdown_to_discord,
)
from messaging.rendering.markdown_tables import (
_is_gfm_table_header_line,
normalize_gfm_tables,
)
class TestEscapeDiscord:
@ -114,20 +116,20 @@ class TestNormalizeGfmTables:
"""Tests for _normalize_gfm_tables."""
def test_single_line_unchanged(self):
assert _normalize_gfm_tables("hello") == "hello"
assert normalize_gfm_tables("hello") == "hello"
def test_two_lines_no_table_unchanged(self):
assert _normalize_gfm_tables("a\nb") == "a\nb"
assert normalize_gfm_tables("a\nb") == "a\nb"
def test_table_gets_blank_line_before(self):
text = "para\n| A | B |\n|---|\n| 1 | 2 |"
result = _normalize_gfm_tables(text)
result = normalize_gfm_tables(text)
assert "para" in result
assert "| A | B |" in result
def test_table_inside_fence_unchanged(self):
text = "```\n| A | B |\n|---|\n```"
result = _normalize_gfm_tables(text)
result = normalize_gfm_tables(text)
assert result == text

View file

@ -204,15 +204,15 @@ def test_convert_assistant_message_thinking():
assert "reasoning_content" not in result[0]
def test_convert_assistant_message_thinking_include_reasoning_for_openrouter():
"""When include_reasoning_for_openrouter=True, reasoning_content is added."""
def test_convert_assistant_message_thinking_include_reasoning_content():
"""When include_reasoning_content=True, reasoning_content is added."""
content = [
MockBlock(type="thinking", thinking="I need to calculate this."),
MockBlock(type="text", text="The answer is 4."),
]
messages = [MockMessage("assistant", content)]
result = AnthropicToOpenAIConverter.convert_messages(
messages, include_reasoning_for_openrouter=True
messages, include_reasoning_content=True
)
assert len(result) == 1
@ -229,7 +229,7 @@ def test_convert_assistant_message_thinking_removed_when_disabled():
result = AnthropicToOpenAIConverter.convert_messages(
messages,
include_thinking=False,
include_reasoning_for_openrouter=True,
include_reasoning_content=True,
)
assert len(result) == 1

View file

@ -55,8 +55,8 @@ def llamacpp_config():
@pytest.fixture(autouse=True)
def mock_rate_limiter():
"""Mock the global rate limiter to prevent waiting."""
with patch("providers.llamacpp.client.GlobalRateLimiter") as mock:
instance = mock.get_instance.return_value
with patch("providers.anthropic_messages.GlobalRateLimiter") as mock:
instance = mock.get_scoped_instance.return_value
instance.wait_if_blocked = AsyncMock(return_value=False)
async def _passthrough(fn, *args, **kwargs):

View file

@ -55,8 +55,8 @@ def lmstudio_config():
@pytest.fixture(autouse=True)
def mock_rate_limiter():
"""Mock the global rate limiter to prevent waiting."""
with patch("providers.lmstudio.client.GlobalRateLimiter") as mock:
instance = mock.get_instance.return_value
with patch("providers.anthropic_messages.GlobalRateLimiter") as mock:
instance = mock.get_scoped_instance.return_value
instance.wait_if_blocked = AsyncMock(return_value=False)
async def _passthrough(fn, *args, **kwargs):

View file

@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from providers.base import ProviderConfig
from providers.open_router import OpenRouterChatProvider, OpenRouterProvider
from providers.open_router import OpenRouterProvider
from providers.open_router.request import OPENROUTER_DEFAULT_MAX_TOKENS
@ -453,13 +453,3 @@ async def test_stream_response_error_path(open_router_provider):
assert "message_start" in event_text
assert "API failed" in event_text
assert "message_stop" in event_text
def test_openai_chat_rollback_provider_builds_legacy_extra_body(open_router_config):
with patch("providers.openai_compat.AsyncOpenAI"):
provider = OpenRouterChatProvider(open_router_config)
body = provider._build_request_body(MockRequest())
assert "extra_body" in body
assert body["extra_body"]["reasoning"] == {"enabled": True}

View file

@ -7,7 +7,7 @@ from providers.deepseek import DeepSeekProvider
from providers.llamacpp import LlamaCppProvider
from providers.lmstudio import LMStudioProvider
from providers.nvidia_nim import NvidiaNimProvider
from providers.open_router import OpenRouterChatProvider, OpenRouterProvider
from providers.open_router import OpenRouterProvider
from providers.registry import (
PROVIDER_DESCRIPTORS,
ProviderRegistry,
@ -35,7 +35,6 @@ def _make_settings(**overrides):
mock.http_write_timeout = 10.0
mock.http_connect_timeout = 2.0
mock.enable_thinking = True
mock.openrouter_transport = "anthropic"
mock.nim = NimSettings()
for key, value in overrides.items():
setattr(mock, key, value)
@ -63,15 +62,6 @@ def test_create_provider_uses_native_openrouter_by_default():
assert isinstance(provider, OpenRouterProvider)
def test_create_provider_can_use_openrouter_openai_rollback():
with patch("providers.openai_compat.AsyncOpenAI"):
provider = create_provider(
"open_router", _make_settings(openrouter_transport="openai")
)
assert isinstance(provider, OpenRouterChatProvider)
def test_create_provider_instantiates_each_builtin():
settings = _make_settings()
cases = {