Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,5 @@ docs/HOSTED_PRODUCT_UPGRADE_PLAN.md
docs/HOSTED_IMPLEMENTATION_GUIDE.md
docs/HOSTED_PROGRESS.md
scripts/md_to_docx.py

# Local issue analysis scratch (not for OSS distribution)
8 changes: 8 additions & 0 deletions packages/cli/src/repowise/cli/commands/init_cmd/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,14 @@ def _run_generation_phase(
lang_parts = [f"{lang} {pct:.0%}" for lang, pct in lang_items]
console.print(f" Languages: {', '.join(lang_parts)}")

# Warn when a local provider runs with default concurrency
if provider.provider_name in ("ollama", "codex_cli", "opencode") and concurrency > 4:
console.print(
f" [yellow]Warning:[/yellow] {provider.provider_name} is a local provider "
f"running with concurrency={concurrency}. "
f"If you see timeout errors, try [bold]--concurrency 1[/bold]."
)

console.print(
f" Coverage: {int(chosen_pct * 100)}% / "
f"~{est.estimated_input_tokens + est.estimated_output_tokens:,} tokens "
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/repowise/core/providers/llm/deepseek.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from typing import TYPE_CHECKING, Any

import structlog
from openai import APIError as _OpenAIAPIError
from openai import APIStatusError as _OpenAIAPIStatusError
from openai import AsyncOpenAI
from openai import RateLimitError as _OpenAIRateLimitError
Expand Down Expand Up @@ -267,6 +268,10 @@ async def _generate_with_retry(
) from exc
except _OpenAIAPIStatusError as exc:
raise ProviderError("deepseek", str(exc), status_code=exc.status_code) from exc
except _OpenAIAPIError as exc:
raise ProviderError(
"deepseek", str(exc), status_code=getattr(exc, "status_code", None)
) from exc

usage = response.usage
result = GeneratedResponse(
Expand Down Expand Up @@ -341,6 +346,10 @@ async def stream_chat(
) from exc
except _OpenAIAPIStatusError as exc:
raise ProviderError("deepseek", str(exc), status_code=exc.status_code) from exc
except _OpenAIAPIError as exc:
raise ProviderError(
"deepseek", str(exc), status_code=getattr(exc, "status_code", None)
) from exc

tool_calls_acc: dict[int, dict[str, Any]] = {}

Expand Down Expand Up @@ -410,3 +419,7 @@ async def stream_chat(
) from exc
except _OpenAIAPIStatusError as exc:
raise ProviderError("deepseek", str(exc), status_code=exc.status_code) from exc
except _OpenAIAPIError as exc:
raise ProviderError(
"deepseek", str(exc), status_code=getattr(exc, "status_code", None)
) from exc
66 changes: 51 additions & 15 deletions packages/core/src/repowise/core/providers/llm/ollama.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,11 @@
from typing import Any

import structlog
from openai import APIError as _OpenAIAPIError
from openai import APIStatusError as _OpenAIAPIStatusError
from openai import AsyncOpenAI
from tenacity import (
RetryError,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential_jitter,
)
from openai import RateLimitError as _OpenAIRateLimitError
from tenacity import RetryError, retry

from repowise.core.providers.llm.base import (
BaseProvider,
Expand All @@ -42,18 +38,19 @@
GeneratedResponse,
ProviderError,
ProviderModelOption,
RateLimitError,
ensure_reasoning_supported,
fallback_model_option,
parse_retry_after,
provider_retry_stop,
provider_retry_wait,
provider_should_retry,
)
from repowise.core.rate_limiter import RateLimiter
from repowise.core.reasoning import ReasoningMode

log = structlog.get_logger(__name__)

_MAX_RETRIES = 3
_MIN_WAIT = 1.0
_MAX_WAIT = 8.0 # Ollama can be slow on first load, allow more wait time

_DEFAULT_BASE_URL = "http://localhost:11434"


Expand Down Expand Up @@ -188,13 +185,13 @@ async def generate(
except RetryError as exc:
raise ProviderError(
"ollama",
f"All {_MAX_RETRIES} retries exhausted: {exc}",
f"All retries exhausted: {exc}",
) from exc

@retry(
retry=retry_if_exception_type(ProviderError),
stop=stop_after_attempt(_MAX_RETRIES),
wait=wait_exponential_jitter(initial=_MIN_WAIT, max=_MAX_WAIT),
retry=provider_should_retry,
stop=provider_retry_stop,
wait=provider_retry_wait,
reraise=True,
)
async def _generate_with_retry(
Expand All @@ -215,8 +212,21 @@ async def _generate_with_retry(
{"role": "user", "content": user_prompt},
],
)
except _OpenAIRateLimitError as exc:
raise RateLimitError(
"ollama",
str(exc),
status_code=429,
retry_after=parse_retry_after(
getattr(getattr(exc, "response", None), "headers", None)
),
) from exc
except _OpenAIAPIStatusError as exc:
raise ProviderError("ollama", str(exc), status_code=exc.status_code) from exc
except _OpenAIAPIError as exc:
raise ProviderError(
"ollama", str(exc), status_code=getattr(exc, "status_code", None)
) from exc

usage = response.usage
result = GeneratedResponse(
Expand Down Expand Up @@ -265,8 +275,21 @@ async def stream_chat(

try:
stream = await self._client.chat.completions.create(**kwargs)
except _OpenAIRateLimitError as exc:
raise RateLimitError(
"ollama",
str(exc),
status_code=429,
retry_after=parse_retry_after(
getattr(getattr(exc, "response", None), "headers", None)
),
) from exc
except _OpenAIAPIStatusError as exc:
raise ProviderError("ollama", str(exc), status_code=exc.status_code) from exc
except _OpenAIAPIError as exc:
raise ProviderError(
"ollama", str(exc), status_code=getattr(exc, "status_code", None)
) from exc

tool_calls_acc: dict[int, dict[str, Any]] = {}

Expand Down Expand Up @@ -314,5 +337,18 @@ async def stream_chat(
tool_calls_acc.clear()
stop_reason = "tool_use" if finish == "tool_calls" else "end_turn"
yield ChatStreamEvent(type="stop", stop_reason=stop_reason)
except _OpenAIRateLimitError as exc:
raise RateLimitError(
"ollama",
str(exc),
status_code=429,
retry_after=parse_retry_after(
getattr(getattr(exc, "response", None), "headers", None)
),
) from exc
except _OpenAIAPIStatusError as exc:
raise ProviderError("ollama", str(exc), status_code=exc.status_code) from exc
except _OpenAIAPIError as exc:
raise ProviderError(
"ollama", str(exc), status_code=getattr(exc, "status_code", None)
) from exc
13 changes: 13 additions & 0 deletions packages/core/src/repowise/core/providers/llm/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from typing import TYPE_CHECKING, Any

import structlog
from openai import APIError as _OpenAIAPIError
from openai import APIStatusError as _OpenAIAPIStatusError
from openai import AsyncOpenAI
from openai import RateLimitError as _OpenAIRateLimitError
Expand Down Expand Up @@ -324,6 +325,10 @@ async def _generate_with_retry(
) from exc
except _OpenAIAPIStatusError as exc:
raise ProviderError("openai", str(exc), status_code=exc.status_code) from exc
except _OpenAIAPIError as exc:
raise ProviderError(
"openai", str(exc), status_code=getattr(exc, "status_code", None)
) from exc

usage = response.usage
cached = 0
Expand Down Expand Up @@ -406,6 +411,10 @@ async def stream_chat(
) from exc
except _OpenAIAPIStatusError as exc:
raise ProviderError("openai", str(exc), status_code=exc.status_code) from exc
except _OpenAIAPIError as exc:
raise ProviderError(
"openai", str(exc), status_code=getattr(exc, "status_code", None)
) from exc

# Track in-progress tool calls (OpenAI streams them incrementally)
tool_calls_acc: dict[int, dict[str, Any]] = {}
Expand Down Expand Up @@ -479,3 +488,7 @@ async def stream_chat(
) from exc
except _OpenAIAPIStatusError as exc:
raise ProviderError("openai", str(exc), status_code=exc.status_code) from exc
except _OpenAIAPIError as exc:
raise ProviderError(
"openai", str(exc), status_code=getattr(exc, "status_code", None)
) from exc
13 changes: 13 additions & 0 deletions packages/core/src/repowise/core/providers/llm/openrouter.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from typing import TYPE_CHECKING, Any

import structlog
from openai import APIError as _OpenAIAPIError
from openai import APIStatusError as _OpenAIAPIStatusError
from openai import AsyncOpenAI
from openai import RateLimitError as _OpenAIRateLimitError
Expand Down Expand Up @@ -330,6 +331,10 @@ async def _generate_with_retry(
) from exc
except _OpenAIAPIStatusError as exc:
raise ProviderError("openrouter", str(exc), status_code=exc.status_code) from exc
except _OpenAIAPIError as exc:
raise ProviderError(
"openrouter", str(exc), status_code=getattr(exc, "status_code", None)
) from exc

usage = response.usage
result = GeneratedResponse(
Expand Down Expand Up @@ -390,6 +395,10 @@ async def stream_chat(
) from exc
except _OpenAIAPIStatusError as exc:
raise ProviderError("openrouter", str(exc), status_code=exc.status_code) from exc
except _OpenAIAPIError as exc:
raise ProviderError(
"openrouter", str(exc), status_code=getattr(exc, "status_code", None)
) from exc

# Track in-progress tool calls (OpenAI-compatible streaming)
tool_calls_acc: dict[int, dict[str, Any]] = {}
Expand Down Expand Up @@ -463,3 +472,7 @@ async def stream_chat(
) from exc
except _OpenAIAPIStatusError as exc:
raise ProviderError("openrouter", str(exc), status_code=exc.status_code) from exc
except _OpenAIAPIError as exc:
raise ProviderError(
"openrouter", str(exc), status_code=getattr(exc, "status_code", None)
) from exc
72 changes: 70 additions & 2 deletions tests/unit/test_providers/test_ollama_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,16 @@

from __future__ import annotations

from unittest.mock import AsyncMock

import httpx
import pytest

pytest.importorskip("openai", reason="openai SDK not installed")

from openai import APIConnectionError, APIError, APIStatusError, APITimeoutError

from repowise.core.providers.llm.base import ProviderError
from repowise.core.providers.llm.ollama import OllamaProvider


Expand Down Expand Up @@ -40,9 +46,71 @@ def fake_get(url, *, timeout):
options = OllamaProvider(base_url="http://localhost:11434").available_model_options()

assert captured["url"] == "http://localhost:11434/api/tags"
models = [option.model for option in options]
assert models == ["llama3.2:latest", "qwen2.5-coder:7b"]
model_names = [option.model for option in options]
assert model_names == ["llama3.2:latest", "qwen2.5-coder:7b"]
llama = options[0]
assert llama.source == "local"
assert llama.notes == "llama, 3B"
assert llama.reasoning_modes == ("auto",)


async def _assert_generate_wraps(exc: Exception) -> None:
"""Drive ``generate`` with a client that raises ``exc`` and assert the
error is caught and re-raised as a provider-tagged ``ProviderError``.

Shared by the #445 regression cases below: only ``APIStatusError`` used to
be caught, so timeout/connection errors escaped uncaught, skipped tenacity
(which retries ``ProviderError`` only), and surfaced as
``page_generation_failed``.
"""
provider = OllamaProvider(model="test", base_url="http://localhost:9999")
provider._rate_limiter = None
provider._client.chat.completions.create = AsyncMock(side_effect=exc)
with pytest.raises(ProviderError) as exc_info:
await provider.generate(system_prompt="", user_prompt="", max_tokens=10)
assert "ollama" in str(exc_info.value)


def _status_error() -> APIStatusError:
resp = AsyncMock()
resp.status_code = 500
return APIStatusError("Internal Server Error", response=resp, body="")


def _connection_error() -> APIConnectionError:
request = httpx.Request("POST", "http://localhost:9999/v1/chat/completions")
return APIConnectionError(message="Connection refused", request=request)


@pytest.mark.asyncio
@pytest.mark.parametrize(
"make_exc",
[
pytest.param(_status_error, id="api_status_error"),
pytest.param(lambda: APITimeoutError("Request timed out"), id="api_timeout_error"),
pytest.param(_connection_error, id="api_connection_error"),
],
)
async def test_generate_wraps_openai_errors(make_exc):
"""Every ``openai.APIError`` subclass is wrapped as ``ProviderError`` (#445).

``APITimeoutError`` and ``APIConnectionError`` are NOT subclasses of
``APIStatusError``, so the old catch-only-``APIStatusError`` clause let them
escape uncaught, skipping tenacity's retry loop entirely.
"""
await _assert_generate_wraps(make_exc())


@pytest.mark.asyncio
async def test_all_openai_errors_are_subclasses_of_api_error():
"""Verify the class hierarchy: all OpenAI errors inherit from APIError."""
resp = AsyncMock()
resp.status_code = 500
status_err = APIStatusError("err", response=resp, body="")
timeout_err = APITimeoutError("timed out")
request = httpx.Request("POST", "http://localhost:9999/v1/chat/completions")
conn_err = APIConnectionError(message="refused", request=request)

assert isinstance(status_err, APIError), "APIStatusError must be APIError"
assert isinstance(timeout_err, APIError), "APITimeoutError must be APIError"
assert isinstance(conn_err, APIError), "APIConnectionError must be APIError"
Loading
Loading