diff --git a/.env.example b/.env.example index 684b90f..8585f3e 100755 --- a/.env.example +++ b/.env.example @@ -98,6 +98,10 @@ CEREBRAS_API_KEY="" OLLAMA_API_KEY="" +# TokenRouter Config (OpenAI-compatible Chat Completions at api.tokenrouter.com/v1) +TOKENROUTER_API_KEY="" + + # LM Studio Config (local provider, no API key required) LM_STUDIO_BASE_URL="http://localhost:1234/v1" @@ -112,7 +116,7 @@ OLLAMA_BASE_URL="http://localhost:11434" # All Claude model requests are mapped to these models, plain model is fallback # Format: provider_type/model/name -# Valid providers: "nvidia_nim" | "open_router" | "gemini" | "vertex" | "deepseek" | "mistral" | "mistral_codestral" | "opencode" | "opencode_go" | "vercel" | "bedrock" | "huggingface" | "cohere" | "github_models" | "wafer" | "kimi" | "kimi_code" | "minimax" | "cerebras" | "groq" | "sambanova" | "fireworks" | "cloudflare" | "zai" | "ollama_cloud" | "lmstudio" | "llamacpp" | "ollama" +# Valid providers: "nvidia_nim" | "open_router" | "gemini" | "vertex" | "deepseek" | "mistral" | "mistral_codestral" | "opencode" | "opencode_go" | "vercel" | "bedrock" | "huggingface" | "cohere" | "github_models" | "wafer" | "kimi" | "kimi_code" | "minimax" | "cerebras" | "groq" | "sambanova" | "fireworks" | "cloudflare" | "zai" | "ollama_cloud" | "tokenrouter" | "lmstudio" | "llamacpp" | "ollama" MODEL_FABLE= MODEL_OPUS= MODEL_SONNET= @@ -151,6 +155,7 @@ CODEFA_SMOKE_MODEL_VERTEX= CODEFA_SMOKE_MODEL_GROQ= CODEFA_SMOKE_MODEL_SAMBANOVA= CODEFA_SMOKE_MODEL_CEREBRAS= +CODEFA_SMOKE_MODEL_TOKENROUTER= CODEFA_SMOKE_NIM_MODELS= CODEFA_SMOKE_NIM_EXTRA_MODELS= CODEFA_SMOKE_OPENROUTER_FREE_MODELS= @@ -196,6 +201,7 @@ GROQ_PROXY="" SAMBANOVA_PROXY="" CEREBRAS_PROXY="" OLLAMA_CLOUD_PROXY="" +TOKENROUTER_PROXY="" PROVIDER_RATE_LIMIT=1 PROVIDER_RATE_WINDOW=3 diff --git a/README.md b/README.md index af396a2..df5f733 100755 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ codefa-init # Initialize ~/.codefa/.env config | [Cloudflare](https://workers.cloudflare.com) | Workers AI catalog | `cloudflare/@cf/meta/llama-3.3-70b-instruct` | | [Z.ai](https://z.ai) | Z.ai GLM Coding Plan | `zai/glm-4` | | [Ollama Cloud](https://ollama.com) | Ollama Cloud remote | `ollama_cloud/llama3.3` | +| [TokenRouter](https://www.tokenrouter.com) | TokenRouter Unified Gateway | `tokenrouter/claude-3-5-sonnet` | | [LM Studio](https://lmstudio.ai) | Local LM Studio server | `lmstudio/local-model` | | [llama.cpp](https://github.com/ggerganov/llama.cpp) | Local llama.cpp server | `llamacpp/local-model` | | [Ollama](https://ollama.com) | Local Ollama instance | `ollama/llama3.3:latest` | diff --git a/pyproject.toml b/pyproject.toml index b4b4985..e58644f 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "codefa" -version = "1.2.50" +version = "1.2.51" description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" readme = "README.md" requires-python = ">=3.14.0" diff --git a/src/codefa/api/admin_static/admin.js b/src/codefa/api/admin_static/admin.js index f599f06..c433edd 100755 --- a/src/codefa/api/admin_static/admin.js +++ b/src/codefa/api/admin_static/admin.js @@ -1091,6 +1091,12 @@ function getProviderIcon(providerId, displayName) { "color": "#FFFFFF", "bg": "rgba(255, 255, 255, 0.15)" }, + "tokenrouter": { + "path": "M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5", + "color": "#10B981", + "bg": "rgba(16, 185, 129, 0.15)", + "stroke": true + }, "ollama": { "path": "M16.361 10.26a.894.894 0 0 0-.558.47l-.072.148.001.207c0 .193.004.217.059.353.076.193.152.312.291.448.24.238.51.3.872.205a.86.86 0 0 0 .517-.436.752.752 0 0 0 .08-.498c-.064-.453-.33-.782-.724-.897a1.06 1.06 0 0 0-.466 0zm-9.203.005c-.305.096-.533.32-.65.639a1.187 1.187 0 0 0-.06.52c.057.309.31.59.598.667.362.095.632.033.872-.205.14-.136.215-.255.291-.448.055-.136.059-.16.059-.353l.001-.207-.072-.148a.894.894 0 0 0-.565-.472 1.02 1.02 0 0 0-.474.007Zm4.184 2c-.131.071-.223.25-.195.383.031.143.157.288.353.407.105.063.112.072.117.136.004.038-.01.146-.029.243-.02.094-.036.194-.036.222.002.074.07.195.143.253.064.052.076.054.255.059.164.005.198.001.264-.03.169-.082.212-.234.15-.525-.052-.243-.042-.28.087-.355.137-.08.281-.219.324-.314a.365.365 0 0 0-.175-.48.394.394 0 0 0-.181-.033c-.126 0-.207.03-.355.124l-.085.053-.053-.032c-.219-.13-.259-.145-.391-.143a.396.396 0 0 0-.193.032zm.39-2.195c-.373.036-.475.05-.654.086-.291.06-.68.195-.951.328-.94.46-1.589 1.226-1.787 2.114-.04.176-.045.234-.045.53 0 .294.005.357.043.524.264 1.16 1.332 2.017 2.714 2.173.3.033 1.596.033 1.896 0 1.11-.125 2.064-.727 2.493-1.571.114-.226.169-.372.22-.602.039-.167.044-.23.044-.523 0-.297-.005-.355-.045-.531-.288-1.29-1.539-2.304-3.072-2.497a6.873 6.873 0 0 0-.855-.031zm.645.937a3.283 3.283 0 0 1 1.44.514c.223.148.537.458.671.662.166.251.26.508.303.82.02.143.01.251-.043.482-.08.345-.332.705-.672.957a3.115 3.115 0 0 1-.689.348c-.382.122-.632.144-1.525.138-.582-.006-.686-.01-.853-.042-.57-.107-1.022-.334-1.35-.68-.264-.28-.385-.535-.45-.946-.03-.192.025-.509.137-.776.136-.326.488-.73.836-.963.403-.269.934-.46 1.422-.512.187-.02.586-.02.773-.002zm-5.503-11a1.653 1.653 0 0 0-.683.298C5.617.74 5.173 1.666 4.985 2.819c-.07.436-.119 1.04-.119 1.503 0 .544.064 1.24.155 1.721.02.107.031.202.023.208a8.12 8.12 0 0 1-.187.152 5.324 5.324 0 0 0-.949 1.02 5.49 5.49 0 0 0-.94 2.339 6.625 6.625 0 0 0-.023 1.357c.091.78.325 1.438.727 2.04l.13.195-.037.064c-.269.452-.498 1.105-.605 1.732-.084.496-.095.629-.095 1.294 0 .67.009.803.088 1.266.095.555.288 1.143.503 1.534.071.128.243.393.264.407.007.003-.014.067-.046.141a7.405 7.405 0 0 0-.548 1.873c-.062.417-.071.552-.071.991 0 .56.031.832.148 1.279L3.42 24h1.478l-.05-.091c-.297-.552-.325-1.575-.068-2.597.117-.472.25-.819.498-1.296l.148-.29v-.177c0-.165-.003-.184-.057-.293a.915.915 0 0 0-.194-.25 1.74 1.74 0 0 1-.385-.543c-.424-.92-.506-2.286-.208-3.451.124-.486.329-.918.544-1.154a.787.787 0 0 0 .223-.531c0-.195-.07-.355-.224-.522a3.136 3.136 0 0 1-.817-1.729c-.14-.96.114-2.005.69-2.834.563-.814 1.353-1.336 2.237-1.475.199-.033.57-.028.776.01.226.04.367.028.512-.041.179-.085.268-.19.374-.431.093-.215.165-.333.36-.576.234-.29.46-.489.822-.729.413-.27.884-.467 1.352-.561.17-.035.25-.04.569-.04.319 0 .398.005.569.04a4.07 4.07 0 0 1 1.914.997c.117.109.398.457.488.602.034.057.095.177.132.267.105.241.195.346.374.43.14.068.286.082.503.045.343-.058.607-.053.943.016 1.144.23 2.14 1.173 2.581 2.437.385 1.108.276 2.267-.296 3.153-.097.15-.193.27-.333.419-.301.322-.301.722-.001 1.053.493.539.801 1.866.708 3.036-.062.772-.26 1.463-.533 1.854a2.096 2.096 0 0 1-.224.258.916.916 0 0 0-.194.25c-.054.109-.057.128-.057.293v.178l.148.29c.248.476.38.823.498 1.295.253 1.008.231 2.01-.059 2.581a.845.845 0 0 0-.044.098c0 .006.329.009.732.009h.73l.02-.074.036-.134c.019-.076.057-.3.088-.516.029-.217.029-1.016 0-1.258-.11-.875-.295-1.57-.597-2.226-.032-.074-.053-.138-.046-.141.008-.005.057-.074.108-.152.376-.569.607-1.284.724-2.228.031-.26.031-1.378 0-1.628-.083-.645-.182-1.082-.348-1.525a6.083 6.083 0 0 0-.329-.7l-.038-.064.131-.194c.402-.604.636-1.262.727-2.04a6.625 6.625 0 0 0-.024-1.358 5.512 5.512 0 0 0-.939-2.339 5.325 5.325 0 0 0-.95-1.02 8.097 8.097 0 0 1-.186-.152.692.692 0 0 1 .023-.208c.208-1.087.201-2.443-.017-3.503-.19-.924-.535-1.658-.98-2.082-.354-.338-.716-.482-1.15-.455-.996.059-1.8 1.205-2.116 3.01a6.805 6.805 0 0 0-.097.726c0 .036-.007.066-.015.066a.96.96 0 0 1-.149-.078A4.857 4.857 0 0 0 12 3.03c-.832 0-1.687.243-2.456.698a.958.958 0 0 1-.148.078c-.008 0-.015-.03-.015-.066a6.71 6.71 0 0 0-.097-.725C8.997 1.392 8.337.319 7.46.048a2.096 2.096 0 0 0-.585-.041Zm.293 1.402c.248.197.523.759.682 1.388.03.113.06.244.069.292.007.047.026.152.041.233.067.365.098.76.102 1.24l.002.475-.12.175-.118.178h-.278c-.324 0-.646.041-.954.124l-.238.06c-.033.007-.038-.003-.057-.144a8.438 8.438 0 0 1 .016-2.323c.124-.788.413-1.501.696-1.711.067-.05.079-.049.157.013zm9.825-.012c.17.126.358.46.498.888.28.854.36 2.028.212 3.145-.019.14-.024.151-.057.144l-.238-.06a3.693 3.693 0 0 0-.954-.124h-.278l-.119-.178-.119-.175.002-.474c.004-.669.066-1.19.214-1.772.157-.623.434-1.185.68-1.382.078-.062.09-.063.159-.012z", "color": "#FFFFFF", @@ -1212,6 +1218,9 @@ function getProviderFields(providerId) { if (cleanId === 'ollama_cloud') { return key === 'OLLAMA_API_KEY' || key.startsWith('OLLAMA_CLOUD_'); } + if (cleanId === 'tokenrouter') { + return key.startsWith('TOKENROUTER_'); + } if (cleanId === 'ollama') { return key.startsWith('OLLAMA_') && key !== 'OLLAMA_API_KEY'; } diff --git a/src/codefa/config/provider_catalog.py b/src/codefa/config/provider_catalog.py index 08ad752..c21c863 100755 --- a/src/codefa/config/provider_catalog.py +++ b/src/codefa/config/provider_catalog.py @@ -45,6 +45,7 @@ GROQ_DEFAULT_BASE = "https://api.groq.com/openai/v1" CEREBRAS_DEFAULT_BASE = "https://api.cerebras.ai/v1" SAMBANOVA_DEFAULT_BASE = "https://api.sambanova.ai/v1" +TOKENROUTER_DEFAULT_BASE = "https://api.tokenrouter.com/v1" @dataclass(frozen=True, slots=True) @@ -305,6 +306,15 @@ def configuration_attrs(self) -> tuple[str, ...]: default_base_url=OLLAMA_CLOUD_DEFAULT_BASE, proxy_attr="ollama_cloud_proxy", ), + "tokenrouter": ProviderDescriptor( + provider_id="tokenrouter", + display_name="TokenRouter", + credential_env="TOKENROUTER_API_KEY", + credential_url="https://www.tokenrouter.com/", + credential_attr="tokenrouter_api_key", + default_base_url=TOKENROUTER_DEFAULT_BASE, + proxy_attr="tokenrouter_proxy", + ), "lmstudio": ProviderDescriptor( provider_id="lmstudio", display_name="LM Studio", diff --git a/src/codefa/config/settings.py b/src/codefa/config/settings.py index e039dd7..b234be0 100755 --- a/src/codefa/config/settings.py +++ b/src/codefa/config/settings.py @@ -104,6 +104,9 @@ class Settings(BaseSettings): # ==================== Ollama Cloud ==================== ollama_api_key: str = Field(default="", validation_alias="OLLAMA_API_KEY") + # ==================== TokenRouter Config ==================== + tokenrouter_api_key: str = Field(default="", validation_alias="TOKENROUTER_API_KEY") + # ==================== Messaging Platform Selection ==================== # Valid: "telegram" | "discord" | "none" messaging_platform: str = Field( @@ -178,6 +181,7 @@ class Settings(BaseSettings): groq_proxy: str = Field(default="", validation_alias="GROQ_PROXY") cerebras_proxy: str = Field(default="", validation_alias="CEREBRAS_PROXY") ollama_cloud_proxy: str = Field(default="", validation_alias="OLLAMA_CLOUD_PROXY") + tokenrouter_proxy: str = Field(default="", validation_alias="TOKENROUTER_PROXY") # ==================== Provider Rate Limiting ==================== provider_rate_limit: int = Field(default=40, validation_alias="PROVIDER_RATE_LIMIT") diff --git a/src/codefa/providers/openai_chat/profiles.py b/src/codefa/providers/openai_chat/profiles.py index 7f3527f..a16da7e 100755 --- a/src/codefa/providers/openai_chat/profiles.py +++ b/src/codefa/providers/openai_chat/profiles.py @@ -344,6 +344,20 @@ def _policy( ), reasoning_delta_field="reasoning", ), + "tokenrouter": OpenAIChatProfile( + _policy( + "TOKENROUTER", + ReasoningReplayMode.REASONING_CONTENT, + include_extra_body=True, + extra_body_validator=validate_extra_body_does_not_override_canonical_fields, + default_max_tokens=ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS, + ), + NamedEffortReasoning( + _LOW_MEDIUM_HIGH, + disabled_value="none", + enabled_value="high", + ), + ), "llamacpp": OpenAIChatProfile( _policy( "LLAMACPP", diff --git a/tests/contracts/test_provider_catalog_order.py b/tests/contracts/test_provider_catalog_order.py index 0219878..13b3032 100755 --- a/tests/contracts/test_provider_catalog_order.py +++ b/tests/contracts/test_provider_catalog_order.py @@ -31,6 +31,7 @@ "cloudflare", "zai", "ollama_cloud", + "tokenrouter", "lmstudio", "llamacpp", "ollama", diff --git a/tests/providers/test_provider_runtime.py b/tests/providers/test_provider_runtime.py index dba0d01..2cd2692 100755 --- a/tests/providers/test_provider_runtime.py +++ b/tests/providers/test_provider_runtime.py @@ -102,6 +102,8 @@ def _make_settings(**overrides): mock.cerebras_api_key = "" mock.cerebras_proxy = "" mock.ollama_cloud_proxy = "" + mock.tokenrouter_api_key = "test_tokenrouter_key" + mock.tokenrouter_proxy = "" mock.provider_rate_limit = 40 mock.provider_rate_window = 60 mock.provider_max_concurrency = 5 @@ -440,6 +442,7 @@ def test_create_provider_instantiates_each_builtin(): "groq": OpenAIChatProvider, "sambanova": OpenAIChatProvider, "cerebras": OpenAIChatProvider, + "tokenrouter": OpenAIChatProvider, } sentinel_admission = MagicMock(spec=ProviderAdmissionController) diff --git a/tests/providers/test_tokenrouter.py b/tests/providers/test_tokenrouter.py new file mode 100644 index 0000000..299081a --- /dev/null +++ b/tests/providers/test_tokenrouter.py @@ -0,0 +1,87 @@ +"""Tests for the TokenRouter OpenAI-chat provider.""" + +import pytest + +from codefa.config.constants import ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS +from codefa.config.provider_catalog import TOKENROUTER_DEFAULT_BASE +from codefa.core.anthropic.models import Message, MessagesRequest, Tool +from codefa.providers.base import ProviderConfig +from codefa.providers.openai_chat import ( + OpenAIChatProvider, +) +from tests.providers.support import ( + REASONING_OFF, + immediate_admission, + profiled_provider, + reasoning_for, +) + + +@pytest.fixture +def tokenrouter_config(): + return ProviderConfig( + api_key="test-tokenrouter-key", + base_url=TOKENROUTER_DEFAULT_BASE, + rate_limit=10, + rate_window=60, + ) + + +@pytest.fixture +def tokenrouter_provider(tokenrouter_config): + return profiled_provider( + "tokenrouter", + tokenrouter_config, + admission=immediate_admission(), + ) + + +def test_default_base_url(): + assert TOKENROUTER_DEFAULT_BASE == "https://api.tokenrouter.com/v1" + + +def test_init_uses_openai_chat_provider(tokenrouter_provider): + assert isinstance(tokenrouter_provider, OpenAIChatProvider) + assert tokenrouter_provider._api_key == "test-tokenrouter-key" + assert tokenrouter_provider._base_url == TOKENROUTER_DEFAULT_BASE + assert tokenrouter_provider._provider_name == "TOKENROUTER" + + +def test_build_request_body_openai_shape_and_defaults(tokenrouter_provider): + request = MessagesRequest.model_validate( + { + "model": "claude-3-5-sonnet", + "messages": [Message(role="user", content="Hello TokenRouter")], + "tools": [ + Tool( + name="echo", + description="Echo input", + input_schema={"type": "object", "properties": {}}, + ) + ], + "thinking": {"type": "enabled", "budget_tokens": 2048}, + } + ) + + body = tokenrouter_provider._build_request_body( + request, reasoning=reasoning_for(request) + ) + + assert body["model"] == "claude-3-5-sonnet" + assert body["messages"][0] == {"role": "user", "content": "Hello TokenRouter"} + assert body["tools"][0]["function"]["name"] == "echo" + assert body["reasoning_effort"] == "high" + assert body["max_tokens"] == ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS + + +def test_build_request_body_honors_effective_no_thinking(tokenrouter_provider): + request = MessagesRequest.model_validate( + { + "model": "claude-3-5-sonnet", + "messages": [{"role": "user", "content": "Explore the codebase."}], + } + ) + + body = tokenrouter_provider._build_request_body(request, reasoning=REASONING_OFF) + + assert body["reasoning_effort"] == "none" diff --git a/uv.lock b/uv.lock index 64862f7..2007aad 100755 --- a/uv.lock +++ b/uv.lock @@ -272,7 +272,7 @@ wheels = [ [[package]] name = "codefa" -version = "1.2.50" +version = "1.2.51" source = { editable = "." } dependencies = [ { name = "aiohttp" },