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
9 changes: 9 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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.

# -------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
12 changes: 11 additions & 1 deletion py-src/data_formulator/agents/client_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down Expand Up @@ -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}"
Comment on lines +285 to +286

def _strip_image_blocks(self, content):
"""Remove image_url blocks from multimodal content arrays."""
Expand Down
4 changes: 2 additions & 2 deletions py-src/data_formulator/model_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@
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:
"""
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
Expand Down
8 changes: 5 additions & 3 deletions src/views/ModelSelectionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ export const ModelSelectionButton: React.FC<ModelSelectionButtonProps> = ({ appe
'azure': [],
'anthropic': [],
'gemini': [],
'ollama': []
'ollama': [],
'orcarouter': []
});
const serverConfig = useSelector((state: DataFormulatorState) => state.serverConfig);

Expand Down Expand Up @@ -223,7 +224,8 @@ export const ModelSelectionButton: React.FC<ModelSelectionButtonProps> = ({ appe
'azure': [],
'anthropic': [],
'gemini': [],
'ollama': []
'ollama': [],
'orcarouter': []
};

globalModels.forEach((modelConfig: any) => {
Expand Down Expand Up @@ -436,7 +438,7 @@ export const ModelSelectionButton: React.FC<ModelSelectionButtonProps> = ({ appe
setNewModelDiagnostic(null);
}}
>
{['openai', 'azure', 'ollama', 'anthropic', 'gemini'].map(provider => (
{['openai', 'azure', 'ollama', 'anthropic', 'gemini', 'orcarouter'].map(provider => (
<MenuItem key={provider} value={provider}>{provider}</MenuItem>
))}
</TextField>
Expand Down
29 changes: 29 additions & 0 deletions tests/backend/agents/test_client_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
18 changes: 17 additions & 1 deletion tests/backend/agents/test_model_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
})


Expand All @@ -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):
Expand Down Expand Up @@ -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",
Expand Down