mirror of
https://github.com/Alishahryar1/free-claude-code.git
synced 2026-04-28 03:20:01 +00:00
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:
parent
22837720ca
commit
0e3b2c24b4
43 changed files with 356 additions and 615 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
87
PLAN.md
Normal 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`.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
"""Backward-compatible token counting import for API route handlers."""
|
||||
|
||||
from core.anthropic import get_token_count
|
||||
|
||||
__all__ = ["get_token_count"]
|
||||
|
|
@ -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),
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
49
messaging/rendering/markdown_tables.py
Normal file
49
messaging/rendering/markdown_tables.py
Normal 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)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue