diff --git a/.env.template b/.env.template index 7f7c70faa..03d6ce5df 100644 --- a/.env.template +++ b/.env.template @@ -82,6 +82,15 @@ OLLAMA_ENABLED=true OLLAMA_API_BASE=http://localhost:11434 OLLAMA_MODELS=qwen3:32b # models with good code generation capabilities recommended +# OrcaRouter (OpenAI-compatible AI gateway) +# Provides adaptive routing, automatic failover, zero-markup inference, +# observability, guardrails, and agent-tool governance behind one endpoint. +# See: https://www.orcarouter.ai +ORCAROUTER_ENABLED=true +ORCAROUTER_API_KEY=#your-orcarouter-api-key +ORCAROUTER_API_BASE=https://api.orcarouter.ai/v1 +ORCAROUTER_MODELS=auto # comma separated list of models; use e.g. "auto" or "openai/gpt-4.1-mini" + # Add other LiteLLM-supported providers with PROVIDER_API_KEY, PROVIDER_MODELS, etc. # ------------------------------------------------------------------- diff --git a/README.md b/README.md index d8ca361d0..ba30af63b 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Here are milestones that lead to the current design: - **v0.2** ([Demos](https://github.com/microsoft/data-formulator/releases/tag/0.2)): Large data support with DuckDB integration - **v0.1.7** ([Demos](https://github.com/microsoft/data-formulator/releases/tag/0.1.7)): Dataset anchoring for cleaner workflows - **v0.1.6** ([Demo](https://github.com/microsoft/data-formulator/releases/tag/0.1.6)): Multi-table support with automatic joins -- **Model Support**: OpenAI, Azure, Ollama, Anthropic via [LiteLLM](https://github.com/BerriAI/litellm) ([feedback](https://github.com/microsoft/data-formulator/issues/49)) +- **Model Support**: OpenAI, Azure, Ollama, Anthropic, [OrcaRouter](https://www.orcarouter.ai) via [LiteLLM](https://github.com/BerriAI/litellm) ([feedback](https://github.com/microsoft/data-formulator/issues/49)) - **Python Package**: Easy local installation ([try it](#get-started)) - **Visualization Challenges**: Test your skills ([challenges](https://github.com/microsoft/data-formulator/issues/53)) - **Data Extraction**: Parse data from images and text ([demo](https://github.com/microsoft/data-formulator/pull/31#issuecomment-2403652717)) diff --git a/py-src/data_formulator/agents/client_utils.py b/py-src/data_formulator/agents/client_utils.py index 869c8a4f3..cfff73794 100644 --- a/py-src/data_formulator/agents/client_utils.py +++ b/py-src/data_formulator/agents/client_utils.py @@ -219,7 +219,7 @@ def _salvage_tool_calls_from_content(response, tools): class Client(object): """ Returns a LiteLLM client configured for the specified endpoint and model. - Supports OpenAI, Azure, Ollama, and other providers via LiteLLM. + Supports OpenAI, Azure, Ollama, OrcaRouter, and other providers via LiteLLM. """ def __init__(self, endpoint, model, api_key=None, api_base=None, api_version=None): @@ -274,6 +274,16 @@ def __init__(self, endpoint, model, api_key=None, api_base=None, api_version=No self.model = model else: self.model = f"ollama/{model}" + elif self.endpoint == "orcarouter": + # OrcaRouter exposes an OpenAI-compatible API, so route the model + # through LiteLLM's openai provider against the OrcaRouter base URL. + # The ``orcarouter/`` prefix is preserved by LiteLLM (unlike + # ``openai/``, which it strips), which is how OrcaRouter's gateway + # addresses its model routers. + self.params["api_base"] = (api_base or "https://api.orcarouter.ai/v1").rstrip("/") + self.params["custom_llm_provider"] = "openai" + if "/" not in model: + self.model = f"orcarouter/{model}" def _strip_image_blocks(self, content): """Remove image_url blocks from multimodal content arrays.""" diff --git a/py-src/data_formulator/model_registry.py b/py-src/data_formulator/model_registry.py index a91347d79..814df569b 100644 --- a/py-src/data_formulator/model_registry.py +++ b/py-src/data_formulator/model_registry.py @@ -4,7 +4,7 @@ import os from typing import Optional, Dict, List -BUILTIN_PROVIDERS = {'openai', 'azure', 'anthropic', 'gemini', 'ollama'} +BUILTIN_PROVIDERS = {'openai', 'azure', 'anthropic', 'gemini', 'ollama', 'orcarouter'} class ModelRegistry: @@ -12,7 +12,7 @@ class ModelRegistry: Load global model configurations from environment variables. Supports both built-in providers (openai / azure / anthropic / gemini / - ollama) and arbitrary custom providers (e.g. DEEPSEEK, QWEN). + ollama / orcarouter) and arbitrary custom providers (e.g. DEEPSEEK, QWEN). For a custom provider, set: {PROVIDER}_ENABLED=true diff --git a/src/views/ModelSelectionDialog.tsx b/src/views/ModelSelectionDialog.tsx index e1928af57..f7a217c5c 100644 --- a/src/views/ModelSelectionDialog.tsx +++ b/src/views/ModelSelectionDialog.tsx @@ -109,7 +109,8 @@ export const ModelSelectionButton: React.FC = ({ appe 'azure': [], 'anthropic': [], 'gemini': [], - 'ollama': [] + 'ollama': [], + 'orcarouter': [] }); const serverConfig = useSelector((state: DataFormulatorState) => state.serverConfig); @@ -223,7 +224,8 @@ export const ModelSelectionButton: React.FC = ({ appe 'azure': [], 'anthropic': [], 'gemini': [], - 'ollama': [] + 'ollama': [], + 'orcarouter': [] }; globalModels.forEach((modelConfig: any) => { @@ -436,7 +438,7 @@ export const ModelSelectionButton: React.FC = ({ appe setNewModelDiagnostic(null); }} > - {['openai', 'azure', 'ollama', 'anthropic', 'gemini'].map(provider => ( + {['openai', 'azure', 'ollama', 'anthropic', 'gemini', 'orcarouter'].map(provider => ( {provider} ))} diff --git a/tests/backend/agents/test_client_utils.py b/tests/backend/agents/test_client_utils.py index a8878193f..9349b9185 100644 --- a/tests/backend/agents/test_client_utils.py +++ b/tests/backend/agents/test_client_utils.py @@ -56,6 +56,35 @@ def test_openai_model_prefixed(self): assert c.model == "openai/gpt-4o" +# --------------------------------------------------------------------------- +# OrcaRouter endpoint +# --------------------------------------------------------------------------- + +class TestOrcaRouter: + def test_default_base_url(self): + c = Client("orcarouter", "auto", api_key="k") + assert c.params["api_base"] == "https://api.orcarouter.ai/v1" + + def test_custom_base_url_strips_trailing_slash(self): + c = Client("orcarouter", "auto", api_key="k", + api_base="https://api.orcarouter.ai/v1/") + assert c.params["api_base"] == "https://api.orcarouter.ai/v1" + + def test_uses_openai_compatible_provider(self): + c = Client("orcarouter", "auto", api_key="k") + assert c.params["custom_llm_provider"] == "openai" + + def test_model_prefixed_with_orcarouter_namespace(self): + """The ``orcarouter/`` prefix is preserved by LiteLLM (unlike + ``openai/``, which it strips) so OrcaRouter's gateway can route it.""" + c = Client("orcarouter", "auto", api_key="k") + assert c.model == "orcarouter/auto" + + def test_model_prefix_not_doubled(self): + c = Client("orcarouter", "orcarouter/auto", api_key="k") + assert c.model == "orcarouter/auto" + + # --------------------------------------------------------------------------- # Ollama api_base normalisation # --------------------------------------------------------------------------- diff --git a/tests/backend/agents/test_model_registry.py b/tests/backend/agents/test_model_registry.py index 2f46990be..2fb41b338 100644 --- a/tests/backend/agents/test_model_registry.py +++ b/tests/backend/agents/test_model_registry.py @@ -50,6 +50,12 @@ def _make_env(providers: dict[str, dict[str, str]]) -> dict[str, str]: "api_base": "https://api.deepseek.com/v1", "models": "deepseek-chat", }, + "orcarouter": { + "enabled": "true", + "api_key": "sk-orca-secret-key", + "api_base": "https://api.orcarouter.ai/v1", + "models": "auto", + }, }) @@ -68,11 +74,12 @@ def test_discovers_all_enabled_providers(self): assert "global-openai-gpt-5" in ids assert "global-ollama-qwen3:32b" in ids assert "global-deepseek-deepseek-chat" in ids + assert "global-orcarouter-auto" in ids @patch.dict(os.environ, SAMPLE_ENV, clear=True) def test_total_model_count(self): registry = ModelRegistry() - assert len(registry.list_public()) == 4 # 2 openai + 1 ollama + 1 deepseek + assert len(registry.list_public()) == 5 # 2 openai + 1 ollama + 1 deepseek + 1 orcarouter @patch.dict(os.environ, {}, clear=True) def test_empty_env_yields_no_models(self): @@ -141,6 +148,15 @@ def test_builtin_provider_uses_own_name_as_endpoint(self): assert config is not None assert config["endpoint"] == "openai" + @patch.dict(os.environ, SAMPLE_ENV, clear=True) + def test_orcarouter_builtin_uses_own_name_as_endpoint(self): + """orcarouter is in BUILTIN_PROVIDERS, so it uses itself as endpoint.""" + registry = ModelRegistry() + config = registry.get_config("global-orcarouter-auto") + assert config is not None + assert config["endpoint"] == "orcarouter" + assert config["api_base"] == "https://api.orcarouter.ai/v1" + @patch.dict(os.environ, { "MYVENDOR_ENABLED": "true", "MYVENDOR_API_KEY": "key123",