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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions examples/06_openai_codex_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def parse_args() -> argparse.Namespace:
)
parser.add_argument(
"--model",
default=os.getenv("REPUBLIC_CODEX_MODEL", "openai:gpt-5-codex"),
default=os.getenv("REPUBLIC_CODEX_MODEL", "openai:gpt-5.3-codex"),
help="Model to use after login.",
)
parser.add_argument(
Expand All @@ -48,13 +48,15 @@ def prompt_for_redirect(authorize_url: str) -> str:
def main() -> None:
args = parse_args()

resolver = openai_codex_oauth_resolver()
tokens = load_openai_codex_oauth_tokens()
if tokens is None or args.force_login:
if args.force_login or resolver("openai") is None:
tokens = login_openai_codex_oauth(
prompt_for_redirect=None,
)
print("login: ok")
else:
tokens = load_openai_codex_oauth_tokens()
print("login: reused")
print("account_id:", tokens.account_id or "-")

Expand All @@ -63,7 +65,7 @@ def main() -> None:

llm = LLM(
model=args.model,
api_key_resolver=openai_codex_oauth_resolver(),
api_key_resolver=resolver,
)
out = llm.chat(args.prompt)
print("text:", out)
Expand Down
6 changes: 3 additions & 3 deletions examples/07_github_copilot_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,14 +121,14 @@ def _run_mock(model: str) -> None:
import republic.core.execution as execution_module

original_http_client = auth_module.httpx.Client
original_anyllm_create = execution_module.AnyLLM.create
original_create_anyllm_client = execution_module.create_anyllm_client

def _create_mock_client(provider: str, **kwargs: Any) -> FakeGitHubModelsClient:
del provider, kwargs
return FakeGitHubModelsClient()

auth_module.httpx.Client = FakeHTTPClient
execution_module.AnyLLM.create = _create_mock_client
execution_module.create_anyllm_client = _create_mock_client

try:
with tempfile.TemporaryDirectory(prefix="republic-copilot-smoke-") as temp_dir:
Expand All @@ -151,7 +151,7 @@ def _create_mock_client(provider: str, **kwargs: Any) -> FakeGitHubModelsClient:
print("mock chat:", text)
finally:
auth_module.httpx.Client = original_http_client
execution_module.AnyLLM.create = original_anyllm_create
execution_module.create_anyllm_client = original_create_anyllm_client


def _run_live(model: str, prompt: str) -> None:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ keywords = [
]
requires-python = ">=3.11,<4.0"
dependencies = [
"any-llm-sdk>=1.13.0",
"any-llm-sdk>=1.15.0",
"authlib>=1.6.5",
"httpx>=0.28.1",
"pydantic>=2.7.0",
Expand Down
1 change: 1 addition & 0 deletions src/republic/clients/github_copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class GitHubCopilotProvider(BaseOpenAIProvider):
API_BASE = DEFAULT_GITHUB_COPILOT_API_BASE
SUPPORTS_RESPONSES = False
SUPPORTS_EMBEDDING = False
SUPPORTS_MODERATION = False
SUPPORTS_LIST_MODELS = False
SUPPORTS_BATCH = False

Expand Down
2 changes: 1 addition & 1 deletion src/republic/clients/openai_codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def _build_response(
payload: dict[str, Any] = {
"id": getattr(completed_response, "id", None) or "resp_codex",
"created_at": getattr(completed_response, "created_at", None) or 0,
"model": getattr(completed_response, "model", None) or "gpt-5-codex",
"model": getattr(completed_response, "model", None) or "gpt-5.3-codex",
"object": getattr(completed_response, "object", None) or "response",
"output": OpenAICodexProvider._build_response_output(
completed_response=completed_response,
Expand Down
22 changes: 12 additions & 10 deletions src/republic/core/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,17 @@ def resolve_model_provider(model: str, provider: str | None) -> tuple[str, str]:
)
return provider, model

if ":" not in model:
raise RepublicError(ErrorKind.INVALID_INPUT, "Model must be in 'provider:model' format.")
try:
provider_name, model_id = AnyLLM.split_model_provider(model)
except Exception as exc:
if ":" not in model:
raise RepublicError(ErrorKind.INVALID_INPUT, "Model must be in 'provider:model' format.") from exc
provider_name, model_id = model.split(":", 1)

provider_name, model_id = model.split(":", 1)
if not provider_name or not model_id:
provider_value = getattr(provider_name, "value", provider_name)
if not provider_value or not model_id:
raise RepublicError(ErrorKind.INVALID_INPUT, "Model must be in 'provider:model' format.")
return provider_name, model_id
return str(provider_value), model_id

def resolve_fallback(self, model: str) -> tuple[str, str]:
if ":" in model:
Expand Down Expand Up @@ -369,10 +373,9 @@ def _decide_kwargs_for_provider(
self, provider: str, max_tokens: int | None, kwargs: dict[str, Any]
) -> dict[str, Any]:
clean_kwargs = dict(kwargs)
max_tokens_arg = provider_policies.completion_max_tokens_arg(provider)
if max_tokens_arg in clean_kwargs:
if "max_tokens" in clean_kwargs or max_tokens is None:
return clean_kwargs
return {**clean_kwargs, max_tokens_arg: max_tokens}
return {**clean_kwargs, "max_tokens": max_tokens}

def _decide_responses_kwargs(
self,
Expand Down Expand Up @@ -461,14 +464,13 @@ def _selected_transport(
):
raise RepublicError(
ErrorKind.INVALID_INPUT,
f"{provider_name}:{model_id}: messages format is only valid for Anthropic models",
f"{provider_name}:{model_id}: messages format is not supported by this provider",
)
return "messages"

reason = provider_policies.responses_rejection_reason(
provider_name=provider_name,
model_id=model_id,
has_tools=bool(tools_payload),
supports_responses=bool(getattr(client, "SUPPORTS_RESPONSES", False)),
)
if reason is not None:
Expand Down
54 changes: 27 additions & 27 deletions src/republic/core/provider_policies.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,34 @@
"""Provider policy decisions shared across request paths."""
"""Provider capability decisions shared across request paths."""

from __future__ import annotations

from dataclasses import dataclass

from any_llm import AnyLLM
from any_llm.exceptions import UnsupportedProviderError
from any_llm.types.provider import ProviderMetadata

from republic.clients.github_copilot import GitHubCopilotProvider


@dataclass(frozen=True)
class ProviderPolicy:
enable_responses_without_capability: bool = False
include_usage_in_completion_stream: bool = False
completion_max_tokens_arg: str = "max_tokens"
responses_tools_blocked_model_prefixes: tuple[str, ...] = ()
metadata: ProviderMetadata | None = None


_DEFAULT_POLICY = ProviderPolicy()
_POLICIES: dict[str, ProviderPolicy] = {
"github-copilot": ProviderPolicy(
include_usage_in_completion_stream=True,
completion_max_tokens_arg="max_tokens",
metadata=GitHubCopilotProvider.get_provider_metadata(),
),
# Stream usage is not represented in any-llm provider metadata. Keep this as
# a narrow default for providers whose SDK path accepts OpenAI stream_options.
"openai": ProviderPolicy(
include_usage_in_completion_stream=True,
completion_max_tokens_arg="max_completion_tokens",
),
# any-llm supports OpenRouter responses in practice but still reports SUPPORTS_RESPONSES=False.
"openrouter": ProviderPolicy(
enable_responses_without_capability=True,
include_usage_in_completion_stream=True,
responses_tools_blocked_model_prefixes=("anthropic/",),
),
"openrouter": ProviderPolicy(include_usage_in_completion_stream=True),
}


Expand All @@ -40,37 +40,37 @@ def provider_policy(provider_name: str) -> ProviderPolicy:
return _POLICIES.get(_normalize_provider_name(provider_name), _DEFAULT_POLICY)


def _responses_tools_blocked_for_model(provider_name: str, model_id: str) -> bool:
policy = provider_policy(provider_name)
lowered_model = model_id.strip().lower()
return any(lowered_model.startswith(prefix) for prefix in policy.responses_tools_blocked_model_prefixes)
def provider_metadata(provider_name: str) -> ProviderMetadata | None:
normalized_provider = _normalize_provider_name(provider_name)
local_metadata = provider_policy(normalized_provider).metadata
if local_metadata is not None:
return local_metadata
try:
return AnyLLM.get_provider_class(normalized_provider).get_provider_metadata()
except (AttributeError, ImportError, UnsupportedProviderError):
return None


def responses_rejection_reason(
*,
provider_name: str,
model_id: str,
has_tools: bool,
supports_responses: bool,
) -> str | None:
if has_tools and _responses_tools_blocked_for_model(provider_name, model_id):
return "responses format is not supported for this model when tools are enabled"
if supports_responses:
return None
if provider_policy(provider_name).enable_responses_without_capability:
metadata = provider_metadata(provider_name)
if metadata is not None and metadata.responses:
return None
return "responses format is not supported by this provider"


def supports_messages_format(*, provider_name: str, model_id: str) -> bool:
normalized_provider = _normalize_provider_name(provider_name)
normalized_model = model_id.strip().lower()
return normalized_provider == "anthropic" or normalized_model.startswith("anthropic/")
metadata = provider_metadata(provider_name)
if metadata is not None:
return metadata.messages
return model_id.strip().lower().startswith("anthropic/")


def should_include_completion_stream_usage(provider_name: str) -> bool:
return provider_policy(provider_name).include_usage_in_completion_stream


def completion_max_tokens_arg(provider_name: str) -> str:
return provider_policy(provider_name).completion_max_tokens_arg
4 changes: 2 additions & 2 deletions tests/fakes.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ def make_responses_response(
return Response.model_validate({
"id": "resp_1",
"created_at": 1,
"model": "gpt-5-codex",
"model": "gpt-5.3-codex",
"object": "response",
"output": output,
"parallel_tool_calls": False,
Expand Down Expand Up @@ -279,7 +279,7 @@ def make_responses_completed(usage: dict[str, Any] | None = None) -> Any:
def make_responses_completed_with_empty_output(
usage: dict[str, Any] | None = None,
*,
model: str = "gpt-5-codex",
model: str = "gpt-5.3-codex",
) -> Any:
"""Simulate a Codex backend response.completed event with an SDK Response whose output is empty."""
full_usage: dict[str, Any] = {
Expand Down
2 changes: 1 addition & 1 deletion tests/test_openai_codex_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ async def __anext__(self) -> Any:
def _build_codex_llm(
monkeypatch,
*queued_responses: Any,
model: str = "openai:gpt-5-codex",
model: str = "openai:gpt-5.3-codex",
) -> tuple[LLM, list[dict[str, Any]], list[dict[str, Any]]]:
init_calls: list[dict[str, Any]] = []
api_calls: list[dict[str, Any]] = []
Expand Down
46 changes: 16 additions & 30 deletions tests/test_provider_policies.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,26 @@
from republic.clients.github_copilot import GitHubCopilotProvider
from republic.core import provider_policies


def test_responses_rejection_reason_none_when_openrouter_responses_available() -> None:
assert (
provider_policies.responses_rejection_reason(
provider_name="openrouter",
model_id="openai/gpt-4o-mini",
has_tools=False,
supports_responses=False,
)
is None
def test_responses_rejection_reason_follows_sdk_metadata() -> None:
reason = provider_policies.responses_rejection_reason(
provider_name="openrouter",
model_id="openai/gpt-4o-mini",
supports_responses=False,
)
assert reason == "responses format is not supported by this provider"


def test_responses_rejection_reason_for_provider_without_responses() -> None:
reason = provider_policies.responses_rejection_reason(
provider_name="anthropic",
model_id="claude-3-5-haiku-latest",
has_tools=False,
supports_responses=False,
)
assert reason is not None
assert "not supported" in reason


def test_responses_rejection_reason_for_openrouter_anthropic_tools() -> None:
reason = provider_policies.responses_rejection_reason(
provider_name="openrouter",
model_id="anthropic/claude-3.5-haiku",
has_tools=True,
supports_responses=False,
)
assert reason is not None
assert "tools" in reason


def test_supports_messages_format() -> None:
assert provider_policies.supports_messages_format(
provider_name="anthropic",
Expand All @@ -44,7 +30,7 @@ def test_supports_messages_format() -> None:
provider_name="openrouter",
model_id="anthropic/claude-3.5-haiku",
)
assert not provider_policies.supports_messages_format(
assert provider_policies.supports_messages_format(
provider_name="openai",
model_id="gpt-4o-mini",
)
Expand All @@ -57,13 +43,13 @@ def test_completion_stream_usage_policy() -> None:
assert not provider_policies.should_include_completion_stream_usage("anthropic")


def test_completion_max_tokens_arg_policy() -> None:
assert provider_policies.completion_max_tokens_arg("openai") == "max_completion_tokens"
assert provider_policies.completion_max_tokens_arg("openrouter") == "max_tokens"
assert provider_policies.completion_max_tokens_arg("github-copilot") == "max_tokens"
assert provider_policies.completion_max_tokens_arg("anthropic") == "max_tokens"


def test_provider_policy_uses_exact_match_not_substring() -> None:
assert not provider_policies.should_include_completion_stream_usage("my-openrouter-proxy")
assert provider_policies.completion_max_tokens_arg("my-openrouter-proxy") == "max_tokens"


def test_github_copilot_metadata_matches_provider_capabilities() -> None:
metadata = provider_policies.provider_metadata("github-copilot")

assert metadata is not None
assert metadata.moderation is False
assert GitHubCopilotProvider.SUPPORTS_MODERATION is False
Loading
Loading