From b2963b1f7fd8d8cc6bd426b40ec9dceffa5f9775 Mon Sep 17 00:00:00 2001 From: witbrock Date: Thu, 27 Aug 2026 05:36:50 -0700 Subject: [PATCH] JVNAUTOSCI-2518 Add opt-in OpenRouter provider --- docs/design_index.md | 1 + .../openrouter_provider_deployment.md | 49 ++ .../environments/dev/dev.tfvars.example | 4 + .../environments/prod/prod.tfvars.example | 4 + .../staging/staging.tfvars.example | 4 + infra/openstack/main.tf | 2 + .../cloud-init/von_bootstrap.yaml.tftpl | 11 +- infra/openstack/variables.tf | 27 +- scripts/bootstrap_llm_setting.py | 2 +- scripts/replay_llm_exchange.py | 9 +- .../integrations/internal_mcp/orchestrator.py | 14 +- src/backend/languagemodels/llm_interface.py | 362 +++++++++++++- .../structured_tool_calling/factory.py | 5 + .../providers/openai_client.py | 92 +++- .../structured_tool_calling/transport.py | 38 +- src/backend/server/routes/settings_routes.py | 239 +++++++++- src/backend/server/routes/von_routes.py | 4 +- src/backend/services/adaptive_turn_service.py | 2 +- .../services/llm_api_key_resolution.py | 6 + .../services/model_parameter_service.py | 19 + .../services/model_registry_service.py | 2 +- .../model_execution_budget_policy.py | 2 +- .../workflows/prompt_metadata_resolution.py | 2 +- .../web/von_interface/static/js/chatTab.js | 4 +- .../web/von_interface/static/js/domUtils.js | 2 +- .../web/von_interface/static/js/settings.js | 33 +- .../von_interface/static/js/settingsPage.js | 197 ++++++-- .../static/js/utils/localModelPreferences.js | 99 +++- .../von_interface/templates/settings_tab.html | 25 + tests/backend/test_openrouter_provider.py | 440 ++++++++++++++++++ tests/frontend/localModelPreferences.test.js | 28 ++ .../frontend/settingsOpenRouterModels.test.js | 38 ++ .../infra/test_openstack_deploy_templates.py | 3 + 33 files changed, 1662 insertions(+), 107 deletions(-) create mode 100644 docs/engineering/openrouter_provider_deployment.md create mode 100644 tests/backend/test_openrouter_provider.py create mode 100644 tests/frontend/settingsOpenRouterModels.test.js diff --git a/docs/design_index.md b/docs/design_index.md index 8f8788d20..89a6e51fd 100644 --- a/docs/design_index.md +++ b/docs/design_index.md @@ -107,6 +107,7 @@ from `AGENTS.md`. | [Modern agentic AI primer](engineering/intro_to_modern_agentic_ai_for_coding_agents.md) | Active principle/background guide | Routed background for substantial agent-behaviour planning; not a current-status report | | [VWL manual](engineering/von_workflow_language_manual.md) | Active manual | Canonical reference for VWL vocabulary and runtime interfaces; consult relevant sections, and verify live artefacts/behaviour | | [Prompt programmes and model routing](engineering/prompt_programs_and_model_routing_playbook.md) | Active playbook | Normative only within its stated prompt/model scope | +| [OpenRouter provider deployment](engineering/openrouter_provider_deployment.md) | Active bounded operator note | Operational reference for the opt-in OpenRouter transport; scoped settings and represented model policy remain authoritative | | [Agent memory and enduring knowledge](engineering/agent_memory_and_enduring_knowledge.md) | Active design guide | Normative only within its stated memory/retrieval scope | | [Contextual knowledge evolution](engineering/contextual_knowledge_evolution.md) | Active design guide | Advisory under `AGENTS.md` when assertion/context distinctions are material; it does not select a microtheory formalism or prove present implementation | | [Ontology publication authority](engineering/ontology_publication_authority.md) | Active design/implementation boundary | Operational reference for `JVNAUTOSCI-2632` and represented publication-profile semantics from `JVNAUTOSCI-2671`; distinguish semantic advice and publication from visibility and operational administration, and revalidate the current runtime, profiles, and represented roles before relying on deployment-specific claims | diff --git a/docs/engineering/openrouter_provider_deployment.md b/docs/engineering/openrouter_provider_deployment.md new file mode 100644 index 000000000..c878da7df --- /dev/null +++ b/docs/engineering/openrouter_provider_deployment.md @@ -0,0 +1,49 @@ +# OpenRouter Provider Deployment Note + +- **Kind:** Bounded deployment and operator note +- **Lifecycle:** Active +- **Authority:** Operational guidance for the opt-in OpenRouter transport only; + model eligibility and routing remain in scoped settings and the represented + model registry/workflows +- **Last reviewed:** 27 August 2026 +- **Review trigger:** OpenRouter API, privacy, pricing, or Von provider-policy changes + +OpenRouter is an optional external inference source. It broadens the catalogue +available for evaluation, but configuring a key does not enable a model, alter +the scoped primary, or make OpenRouter a default. An authenticated actor must +still have the exact `openrouter` provider and model slug in their effective +enabled-model setting. + +Configure `OPENROUTER_API_KEY` or, preferably on managed hosts, +`OPENROUTER_API_KEY_FILE`. The OpenStack bootstrap variables are +`bootstrap_openrouter_api_key` and `bootstrap_openrouter_api_key_file`; keep the +inline value outside tracked tfvars. Von uses +`https://openrouter.ai/api/v1` unless `OPENROUTER_BASE_URL` explicitly selects +another compatible endpoint. + +Every Von OpenRouter generation in this release uses Chat Completions and +requires the following provider preferences: + +- `zdr: true`; +- `data_collection: "deny"`; and +- `require_parameters: true`. + +Router metadata is requested so telemetry can retain the requested provider and +model, concrete response model, generation identifier, upstream routing +evidence, token usage, and provider-reported cost when OpenRouter supplies it. +Missing cost or routing data remains unavailable rather than being recorded as +zero. The catalogue is discovery data only and does not grant model authority. + +OpenRouter charges and upstream rate limits vary by model and provider. Check +the current OpenRouter model catalogue and account limits before allowing a +model. Zero-data-retention eligibility and denial of data-collection endpoints +reduce retention exposure but do not make an external broker equivalent to a +local model; prompts still cross Von's trust boundary and request metadata may +be retained. This is why OpenRouter remains explicit and opt-in for private or +organisation-sensitive work. + +This release does not enable OpenRouter Auto Router, cross-model fallback, +Responses API, embeddings, response caching, server tools/plugins/guardrails, +or deployment activation. Same-model upstream selection remains observable +OpenRouter transport behaviour; Von's represented policy still chooses the +model. diff --git a/infra/openstack/environments/dev/dev.tfvars.example b/infra/openstack/environments/dev/dev.tfvars.example index a7145a274..1ef64cea4 100644 --- a/infra/openstack/environments/dev/dev.tfvars.example +++ b/infra/openstack/environments/dev/dev.tfvars.example @@ -95,6 +95,10 @@ bootstrap_openai_api_key_file = "/etc/von/secrets/openai_api_key" # Leave bootstrap_gemini_api_key unset when using TF_VAR_bootstrap_gemini_api_key. # bootstrap_gemini_api_key = "REPLACE_WITH_LOCAL_UNTRACKED_SECRET" bootstrap_gemini_api_key_file = "/etc/von/secrets/gemini_api_key" +# Leave bootstrap_openrouter_api_key unset when using TF_VAR_bootstrap_openrouter_api_key. +# OpenRouter remains opt-in; do not make it the default without an explicit scoped model. +# bootstrap_openrouter_api_key = "REPLACE_WITH_LOCAL_UNTRACKED_SECRET" +bootstrap_openrouter_api_key_file = "/etc/von/secrets/openrouter_api_key" # Optional: seed a scoped model setting after first deploy. # Set all three values together to seed the organisation default. diff --git a/infra/openstack/environments/prod/prod.tfvars.example b/infra/openstack/environments/prod/prod.tfvars.example index 2c48f3295..3f1bfa29b 100644 --- a/infra/openstack/environments/prod/prod.tfvars.example +++ b/infra/openstack/environments/prod/prod.tfvars.example @@ -99,6 +99,10 @@ bootstrap_openai_api_key_file = "/etc/von/secrets/openai_api_key" # Leave bootstrap_gemini_api_key unset when using TF_VAR_bootstrap_gemini_api_key. # bootstrap_gemini_api_key = "REPLACE_WITH_LOCAL_UNTRACKED_SECRET" bootstrap_gemini_api_key_file = "/etc/von/secrets/gemini_api_key" +# Leave bootstrap_openrouter_api_key unset when using TF_VAR_bootstrap_openrouter_api_key. +# OpenRouter remains opt-in; do not make it the default without an explicit scoped model. +# bootstrap_openrouter_api_key = "REPLACE_WITH_LOCAL_UNTRACKED_SECRET" +bootstrap_openrouter_api_key_file = "/etc/von/secrets/openrouter_api_key" # Optional: seed a scoped model setting after first deploy. # Set all three values together to seed the organisation default. diff --git a/infra/openstack/environments/staging/staging.tfvars.example b/infra/openstack/environments/staging/staging.tfvars.example index f24505383..98b287094 100644 --- a/infra/openstack/environments/staging/staging.tfvars.example +++ b/infra/openstack/environments/staging/staging.tfvars.example @@ -91,6 +91,10 @@ bootstrap_openai_api_key_file = "/etc/von/secrets/openai_api_key" # Leave bootstrap_gemini_api_key unset when using TF_VAR_bootstrap_gemini_api_key. # bootstrap_gemini_api_key = "REPLACE_WITH_LOCAL_UNTRACKED_SECRET" bootstrap_gemini_api_key_file = "/etc/von/secrets/gemini_api_key" +# Leave bootstrap_openrouter_api_key unset when using TF_VAR_bootstrap_openrouter_api_key. +# OpenRouter remains opt-in; do not make it the default without an explicit scoped model. +# bootstrap_openrouter_api_key = "REPLACE_WITH_LOCAL_UNTRACKED_SECRET" +bootstrap_openrouter_api_key_file = "/etc/von/secrets/openrouter_api_key" # Optional: seed a scoped model setting after first deploy. # Set all three values together to seed the organisation default. diff --git a/infra/openstack/main.tf b/infra/openstack/main.tf index f93815618..c34418619 100644 --- a/infra/openstack/main.tf +++ b/infra/openstack/main.tf @@ -178,6 +178,8 @@ locals { openai_api_key_file = var.bootstrap_openai_api_key_file gemini_api_key = var.bootstrap_gemini_api_key gemini_api_key_file = var.bootstrap_gemini_api_key_file + openrouter_api_key = var.bootstrap_openrouter_api_key + openrouter_api_key_file = var.bootstrap_openrouter_api_key_file default_llm_provider = var.bootstrap_default_llm_provider default_llm_model = var.bootstrap_default_llm_model default_llm_organisation_concept_id = var.bootstrap_default_llm_organisation_concept_id diff --git a/infra/openstack/templates/cloud-init/von_bootstrap.yaml.tftpl b/infra/openstack/templates/cloud-init/von_bootstrap.yaml.tftpl index bc8f452b7..7bdcdf870 100644 --- a/infra/openstack/templates/cloud-init/von_bootstrap.yaml.tftpl +++ b/infra/openstack/templates/cloud-init/von_bootstrap.yaml.tftpl @@ -43,6 +43,7 @@ write_files: GOOGLE_OAUTH_ENABLE_DYNAMIC_REDIRECTS=${google_oauth_enable_dynamic_redirects ? "true" : "false"} OPENAI_API_KEY_FILE=${openai_api_key_file} GEMINI_API_KEY_FILE=${gemini_api_key_file} + OPENROUTER_API_KEY_FILE=${openrouter_api_key_file} %{ if default_llm_provider != null && lower(default_llm_provider) == "openai" && default_llm_model != null ~} VON_DEFAULT_OPENAI_MODEL=${jsonencode(default_llm_model)} %{ endif ~} @@ -98,6 +99,14 @@ write_files: ${gemini_api_key} %{ endif ~} +%{ if openrouter_api_key != null ~} + - path: ${openrouter_api_key_file} + owner: root:root + permissions: "0600" + content: | + ${openrouter_api_key} +%{ endif ~} + %{ if mongo_uri != null ~} - path: ${mongo_uri_file} owner: root:root @@ -207,7 +216,7 @@ runcmd: - [bash, -lc, "set -euo pipefail; install -d -m 0755 -o root -g root '${backup_directory}' '${central_log_directory}'"] - [bash, -lc, "set -euo pipefail; install -d -m 0755 -o root -g root /etc/von"] - [bash, -lc, "set -euo pipefail; install -d -m 0750 -o root -g ${service_group} /etc/von/secrets"] - - [bash, -lc, "set -euo pipefail; for secret_file in '${google_oauth_client_id_file}' '${google_oauth_client_secret_file}' '${openai_api_key_file}' '${gemini_api_key_file}' '${mongo_uri_file}'; do if [ -f \"$secret_file\" ]; then chown root:${service_group} \"$secret_file\"; chmod 0640 \"$secret_file\"; fi; done"] + - [bash, -lc, "set -euo pipefail; for secret_file in '${google_oauth_client_id_file}' '${google_oauth_client_secret_file}' '${openai_api_key_file}' '${gemini_api_key_file}' '${openrouter_api_key_file}' '${mongo_uri_file}'; do if [ -f \"$secret_file\" ]; then chown root:${service_group} \"$secret_file\"; chmod 0640 \"$secret_file\"; fi; done"] %{ if enable_https && generate_self_signed_cert ~} - [bash, -lc, "set -euo pipefail; if [ ! -f '${tls_cert_path}' ] || [ ! -f '${tls_key_path}' ]; then install -d -m 0755 /etc/ssl/certs /etc/ssl/private; openssl req -x509 -nodes -newkey rsa:4096 -days 365 -subj '/CN=${domain_name}' -keyout '${tls_key_path}' -out '${tls_cert_path}'; chmod 0600 '${tls_key_path}'; fi"] %{ endif ~} diff --git a/infra/openstack/variables.tf b/infra/openstack/variables.tf index ffb31319f..c09424e55 100644 --- a/infra/openstack/variables.tf +++ b/infra/openstack/variables.tf @@ -562,14 +562,37 @@ variable "bootstrap_gemini_api_key_file" { } } +variable "bootstrap_openrouter_api_key" { + type = string + description = "Optional inline OpenRouter API key for managed bootstrap secret-file injection." + default = null + sensitive = true + + validation { + condition = var.bootstrap_openrouter_api_key == null || trimspace(var.bootstrap_openrouter_api_key) != "" + error_message = "bootstrap_openrouter_api_key must be null or non-empty." + } +} + +variable "bootstrap_openrouter_api_key_file" { + type = string + description = "Path used by runtime OPENROUTER_API_KEY_FILE." + default = "/etc/von/secrets/openrouter_api_key" + + validation { + condition = can(regex("^/", var.bootstrap_openrouter_api_key_file)) + error_message = "bootstrap_openrouter_api_key_file must be an absolute Linux path." + } +} + variable "bootstrap_default_llm_provider" { type = string description = "Optional provider to seed as the scoped cloud default LLM setting." default = null validation { - condition = var.bootstrap_default_llm_provider == null || contains(["openai", "gemini", "ollama"], lower(trimspace(var.bootstrap_default_llm_provider))) - error_message = "bootstrap_default_llm_provider must be null, openai, gemini, or ollama." + condition = var.bootstrap_default_llm_provider == null || contains(["openai", "openrouter", "gemini", "ollama"], lower(trimspace(var.bootstrap_default_llm_provider))) + error_message = "bootstrap_default_llm_provider must be null, openai, openrouter, gemini, or ollama." } } diff --git a/scripts/bootstrap_llm_setting.py b/scripts/bootstrap_llm_setting.py index 349b46ba5..9c3752015 100644 --- a/scripts/bootstrap_llm_setting.py +++ b/scripts/bootstrap_llm_setting.py @@ -13,7 +13,7 @@ from pathlib import Path -SUPPORTED_PROVIDERS = {"openai", "gemini", "ollama"} +SUPPORTED_PROVIDERS = {"openai", "openrouter", "gemini", "ollama"} def _normalise_concept_id(raw: str | None) -> str | None: diff --git a/scripts/replay_llm_exchange.py b/scripts/replay_llm_exchange.py index 9f3437d7b..61830227a 100644 --- a/scripts/replay_llm_exchange.py +++ b/scripts/replay_llm_exchange.py @@ -460,6 +460,10 @@ def _client_for_provider( from src.backend.languagemodels.llm_interface import OpenAIClient return OpenAIClient() + if provider_key == "openrouter": + from src.backend.languagemodels.llm_interface import OpenRouterClient + + return OpenRouterClient() if provider_key == "gemini": from src.backend.languagemodels.llm_interface import GeminiClient @@ -744,7 +748,10 @@ def build_arg_parser() -> argparse.ArgumentParser: parser.add_argument("--call-type", help="Filter by call type.") parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT) parser.add_argument("--inspect-only", action="store_true") - parser.add_argument("--provider", choices=["ollama", "openai", "gemini", "mock"]) + parser.add_argument( + "--provider", + choices=["ollama", "openai", "openrouter", "gemini", "mock"], + ) parser.add_argument("--model") parser.add_argument("--timeout", type=float, default=120.0) parser.add_argument("--repeat", type=int, default=1) diff --git a/src/backend/integrations/internal_mcp/orchestrator.py b/src/backend/integrations/internal_mcp/orchestrator.py index e372a1255..f91929bf5 100644 --- a/src/backend/integrations/internal_mcp/orchestrator.py +++ b/src/backend/integrations/internal_mcp/orchestrator.py @@ -356,6 +356,8 @@ def _provider_from_llm_client(llm_client: Any) -> str | None: class_name = type(llm_client).__name__.lower() if "ollama" in class_name: return "ollama" + if "openrouter" in class_name: + return "openrouter" if "openai" in class_name: return "openai" if "gemini" in class_name: @@ -381,7 +383,7 @@ def _model_identifier_looks_local_ollama(model: Any) -> bool: return False if cleaned.startswith("ollama:"): return True - if cleaned.startswith(("openai:", "anthropic:", "gemini:", "azure_openai:")): + if cleaned.startswith(("openai:", "openrouter:", "anthropic:", "gemini:", "azure_openai:")): return False if cleaned.startswith(("gpt-", "o1-", "claude", "gemini")): return False @@ -13582,7 +13584,7 @@ def _normalise_llm_model_name(model: Optional[str]) -> Optional[str]: candidate_tail = candidate lowered_candidate = candidate.lower() - for provider_prefix in ("openai:", "ollama:", "gemini:"): + for provider_prefix in ("openai:", "openrouter:", "ollama:", "gemini:"): if lowered_candidate.startswith(provider_prefix): candidate_tail = candidate.split(":", 1)[1].strip() break @@ -13694,7 +13696,7 @@ def _parse_policy_model_candidate(value: Any) -> _ModelCandidate | None: elif ":" in candidate: provider_prefix, _, model_suffix = candidate.partition(":") provider_prefix = provider_prefix.strip().lower() - if provider_prefix in {"openai", "ollama", "gemini"}: + if provider_prefix in {"openai", "openrouter", "ollama", "gemini"}: provider = provider_prefix model = model_suffix.strip() elif candidate.lower().startswith("ft:"): @@ -13950,7 +13952,7 @@ def _infer_provider_from_model_reference(model_ref: str | None) -> str | None: if ":" in raw and not raw.startswith("#V#"): provider, _, _model = raw.partition(":") provider_text = provider.strip().lower() - if provider_text in {"openai", "ollama", "gemini"}: + if provider_text in {"openai", "openrouter", "ollama", "gemini"}: return provider_text if raw.lower().startswith("ft:"): return "openai" @@ -14984,7 +14986,7 @@ def _create_client_for_candidate( if ( model_implied_provider and model_implied_provider != default_provider - and model_implied_provider in {"openai", "ollama", "gemini"} + and model_implied_provider in {"openai", "openrouter", "ollama", "gemini"} ): requested_provider_client = model_implied_provider client = get_llm_client( @@ -15079,7 +15081,7 @@ def _create_client_for_candidate( if model: telemetry["model"] = model - if provider in {None, "", "openai", "ollama", "gemini"}: + if provider in {None, "", "openai", "openrouter", "ollama", "gemini"}: pass else: telemetry["error"] = "unsupported_provider" diff --git a/src/backend/languagemodels/llm_interface.py b/src/backend/languagemodels/llm_interface.py index 68cf71a93..f5c070df7 100644 --- a/src/backend/languagemodels/llm_interface.py +++ b/src/backend/languagemodels/llm_interface.py @@ -24,13 +24,14 @@ import openai import requests -from ..services.llm_api_key_resolution import get_gemini_api_key +from ..services.llm_api_key_resolution import get_gemini_api_key, get_openrouter_api_key from ..services.settings_service import ( get_openai_env_var, resolve_enabled_llm_settings, resolve_llm_setting, ) from ..services.model_parameter_service import ( + chat_completions_kwargs_from_model_parameters, gemini_kwargs_from_model_parameters, openai_responses_kwargs_from_model_parameters, ) @@ -161,6 +162,8 @@ def infer_llm_client_provider(client: Any) -> Optional[str]: return "ollama" if "openai" in class_ref: return "openai" + if "openrouter" in class_ref: + return "openrouter" if "gemini" in class_ref or "google" in class_ref: return "gemini" return None @@ -528,7 +531,7 @@ def _looks_like_browser_object_model_reference(value: str) -> bool: if not cleaned: return False lowered = cleaned.lower() - for prefix in ("openai:", "ollama:", "gemini:"): + for prefix in ("openai:", "openrouter:", "ollama:", "gemini:"): if lowered.startswith(prefix): cleaned = cleaned.split(":", 1)[1].strip() break @@ -548,6 +551,19 @@ def _extract_openai_model_id(value: str) -> Optional[str]: return cleaned or None +def _extract_openrouter_model_id(value: str) -> Optional[str]: + if not isinstance(value, str): + return None + cleaned = value.strip() + if not cleaned: + return None + if cleaned.lower().startswith("openrouter:"): + cleaned = cleaned.split(":", 1)[1].strip() + if _looks_like_browser_object_model_reference(cleaned): + return None + return cleaned or None + + def _extract_ollama_model_id(value: str) -> Optional[str]: if not isinstance(value, str): return None @@ -616,6 +632,8 @@ def resolve_provider_from_model_concept(model: Optional[str]) -> Optional[str]: if not isinstance(text, str): continue lowered = text.lower() + if "openrouter" in lowered: + return "openrouter" if "openai" in lowered: return "openai" if "ollama" in lowered: @@ -720,6 +738,42 @@ def resolve_openai_model_name(model: Optional[str]) -> Optional[str]: return direct or raw +def resolve_openrouter_model_name(model: Optional[str]) -> Optional[str]: + """Resolve an OpenRouter catalogue slug without inferring its provider.""" + + if not isinstance(model, str): + return model + raw = model.strip() + if not raw or _looks_like_browser_object_model_reference(raw): + return None + direct = _extract_openrouter_model_id(raw) + if not raw.startswith("#V#"): + return direct + + try: + from ..security.access_control import bypass_access_control + from ..services.text_value_service import get_texts_for_concept + + with bypass_access_control(): + rows = get_texts_for_concept(raw, predicate="hasName", limit=20) + except Exception: + rows = [] + candidates: list[str] = [] + for row in rows: + if not isinstance(row, Mapping): + continue + text = row.get("text") + if not isinstance(text, str): + continue + candidate = _extract_openrouter_model_id(text) + if candidate: + candidates.append(candidate) + for candidate in candidates: + if "/" in candidate and not any(char.isspace() for char in candidate): + return candidate + return candidates[0] if candidates else direct + + def _resolve_effective_llm_actor_scope( user_concept_id: Optional[str] = None, org_concept_id: Optional[str] = None, @@ -787,6 +841,8 @@ def _normalise_model_execution_key( model_key = model_key.split(":", 1)[1].strip() if provider_key == "openai": model_key = resolve_openai_model_name(model_key) or model_key + elif provider_key == "openrouter": + model_key = resolve_openrouter_model_name(model_key) or model_key return provider_key, model_key @@ -817,6 +873,7 @@ def assert_model_execution_allowed( provider_label = { "openai": "OpenAI", + "openrouter": "OpenRouter", "gemini": "Gemini", }.get(provider_key, provider_key or "External") if not model_key: @@ -2283,6 +2340,269 @@ def list_models(self) -> List[str]: return models +OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" +OPENROUTER_CONNECTION_ID = "#V#openrouter_provider" +OPENROUTER_PROVIDER_PREFERENCES: Dict[str, Any] = { + "zdr": True, + "data_collection": "deny", + "require_parameters": True, +} + + +class OpenRouterClient(OpenAIClient): + """Opt-in OpenRouter client using its OpenAI-compatible Chat API.""" + + DEFAULT_MODEL = "" + + def __init__( + self, + api_key: Optional[str] = None, + api_key_env_var: str = "OPENROUTER_API_KEY", + *, + base_url: Optional[str] = None, + model_execution_user_concept_id: Optional[str] = None, + model_execution_org_concept_id: Optional[str] = None, + model_execution_actor_scope_bound: bool = False, + ): + self.api_key = api_key or get_openrouter_api_key() or "" + if not self.api_key: + raise ValueError( + "OpenRouter API key not provided or found in OPENROUTER_API_KEY " + "or OPENROUTER_API_KEY_FILE." + ) + self.base_url = str( + base_url or os.getenv("OPENROUTER_BASE_URL") or OPENROUTER_BASE_URL + ).rstrip("/") + self._model_execution_user_concept_id = model_execution_user_concept_id + self._model_execution_org_concept_id = model_execution_org_concept_id + self._model_execution_actor_scope_bound = bool( + model_execution_actor_scope_bound + ) + self.client = openai.OpenAI( + api_key=self.api_key, + base_url=self.base_url, + default_headers={"X-OpenRouter-Metadata": "enabled"}, + ) + self.last_response_metadata: Dict[str, Any] = {} + logger.info("OpenRouterClient initialised.") + + @staticmethod + def _request_provider_preferences( + raw_extra_body: Any = None, + ) -> Dict[str, Any]: + extra_body = dict(raw_extra_body) if isinstance(raw_extra_body, Mapping) else {} + raw_provider = extra_body.get("provider") + provider_preferences = ( + dict(raw_provider) if isinstance(raw_provider, Mapping) else {} + ) + # These privacy and compatibility requirements are the minimum Von + # profile. Callers may add routing preferences but may not weaken them. + provider_preferences.update(OPENROUTER_PROVIDER_PREFERENCES) + extra_body["provider"] = provider_preferences + return extra_body + + def _get_structured_client_config(self, model: Optional[str]) -> LLMClientConfig: + target_model = str(resolve_openrouter_model_name(model) or "").strip() + if not target_model: + raise ValueError("A concrete OpenRouter model slug is required.") + return LLMClientConfig( + model=target_model, + provider="openrouter", + api_key=self.api_key, + base_url=self.base_url, + connection_id=OPENROUTER_CONNECTION_ID, + deployment_id=target_model, + requested_api_surface="chat_completions", + temperature=0.7, + ) + + def generate_with_tools( + self, + prompt: str, + available_tools: List[ToolDefinition], + context: Optional[List[Dict[str, Any]]] = None, + model: Optional[str] = None, + system_message: Optional[str] = None, + llm_params: Optional[Dict[str, Any]] = None, + **provider_options: Any, + ) -> LLMResponse: + options = dict(provider_options) + options["extra_body"] = self._request_provider_preferences( + options.get("extra_body") + ) + raw_extra_headers = options.get("extra_headers") + extra_headers: Dict[str, Any] = ( + dict(cast(Mapping[str, Any], raw_extra_headers)) + if isinstance(raw_extra_headers, Mapping) + else {} + ) + extra_headers["X-OpenRouter-Metadata"] = "enabled" + options["extra_headers"] = extra_headers + response = super().generate_with_tools( + prompt=prompt, + available_tools=available_tools, + context=context, + model=model, + system_message=system_message, + llm_params=llm_params, + **options, + ) + response.transport_metadata["provider_routing_preferences"] = ( + sanitise_transport_telemetry_value( + options["extra_body"]["provider"] + ) + ) + response.transport_metadata["router_metadata_requested"] = True + return response + + @staticmethod + def _openrouter_usage_mapping(usage: Any) -> Dict[str, Any] | None: + if usage is None: + return None + value = lambda key, default=None: ( + usage.get(key, default) + if isinstance(usage, Mapping) + else getattr(usage, key, default) + ) + payload: Dict[str, Any] = { + "prompt_tokens": value("prompt_tokens"), + "completion_tokens": value("completion_tokens"), + "total_tokens": value("total_tokens"), + } + if value("cost") is not None: + payload["provider_reported_cost"] = value("cost") + if value("cost_details") is not None: + payload["provider_reported_cost_details"] = ( + sanitise_transport_telemetry_value(value("cost_details")) + ) + return payload + + def generate( + self, + prompt: str, + context: Optional[List[Dict[str, Any]]] = None, + model: Optional[str] = None, + llm_params: Optional[Dict[str, Any]] = None, + ) -> str: + target_model = str(resolve_openrouter_model_name(model) or "").strip() + if not target_model: + raise ValueError("A concrete OpenRouter model slug is required.") + self._assert_model_execution_allowed(provider="openrouter", model=target_model) + request_started_monotonic = time.monotonic() + llm_params_for_model, request_advisory_seconds = ( + self._split_request_timeout_from_llm_params(llm_params) + ) + request_kwargs = chat_completions_kwargs_from_model_parameters( + llm_params_for_model, + provider="openrouter", + model=target_model, + ) + request_kwargs["extra_body"] = self._request_provider_preferences( + request_kwargs.pop("extra_body", None) + ) + try: + response = self.client.chat.completions.create( # type: ignore[arg-type] + model=target_model, + messages=to_openai_messages( # type: ignore[arg-type] + build_conversation(prompt, context) + ), + **request_kwargs, + ) + actual_model = getattr(response, "model", None) or target_model + content = self.validate_model_response(response, str(actual_model)) + raw_metadata = getattr(response, "openrouter_metadata", None) + if raw_metadata is None and hasattr(response, "model_dump"): + dumped = response.model_dump(exclude_none=True) + if isinstance(dumped, Mapping): + raw_metadata = dumped.get("openrouter_metadata") + transport_metadata: Dict[str, Any] = { + "requested_provider": "openrouter", + "effective_provider": "openrouter", + "requested_model": target_model, + "effective_model": actual_model, + "effective_api_surface": "chat_completions", + "connection_id": OPENROUTER_CONNECTION_ID, + "deployment_id": target_model, + "provider_routing_preferences": sanitise_transport_telemetry_value( + request_kwargs["extra_body"]["provider"] + ), + "router_metadata_requested": True, + "generation_id": sanitise_transport_telemetry_value( + getattr(response, "id", None) + ), + "duration_ms": int( + max(0.0, time.monotonic() - request_started_monotonic) * 1000 + ), + } + if raw_metadata is not None: + transport_metadata["openrouter_metadata"] = ( + sanitise_transport_telemetry_value(raw_metadata, max_depth=6) + ) + self.last_response_metadata = { + "provider": "openrouter", + "api_surface": "chat_completions", + "requested_model": target_model, + "effective_model": actual_model, + "usage": self._openrouter_usage_mapping( + getattr(response, "usage", None) + ), + "transport_metadata": transport_metadata, + "raw_response": { + "id": getattr(response, "id", None), + "model": actual_model, + }, + } + _observe_request_advisory( + provider="openrouter", + advisory_seconds=request_advisory_seconds, + started_monotonic=request_started_monotonic, + ) + return content + except ModelExecutionEligibilityError: + raise + except Exception as exc: + safe_error = sanitise_transport_telemetry_text(str(exc)) + logger.error( + "OpenRouter Chat Completions request failed (error_type=%s): %s", + type(exc).__name__, + safe_error, + ) + raise RuntimeError(f"OpenRouter request failed: {safe_error}") from exc + + def get_embedding(self, text: str, model: Optional[str] = None) -> List[float]: + raise NotImplementedError("OpenRouter embeddings are not enabled in this release.") + + def list_models(self) -> List[str]: + cache_key = "openrouter:models" + now = time.time() + entry = _MODEL_CACHE.get(cache_key) + if entry and now - entry.get("fetched_at", 0) < _MODEL_CACHE_TTL and not entry.get("error"): + return list(entry.get("models", [])) + try: + response = self.client.models.list() + models = sorted( + { + model_id + for item in getattr(response, "data", []) + if isinstance((model_id := getattr(item, "id", None)), str) + and model_id.strip() + } + ) + except Exception as exc: + safe_error = sanitise_transport_telemetry_text(str(exc)) + _MODEL_FAIL_BACKOFF[cache_key] = now + raise RuntimeError( + f"OpenRouter model listing failed: {safe_error}" + ) from exc + _MODEL_CACHE[cache_key] = { + "models": models, + "fetched_at": now, + "error": None, + "source": "refresh", + } + return models + + class GeminiClient(LLMInterface): """Client for interacting with the Google Gemini API.""" @@ -2917,6 +3237,31 @@ def get_llm_client( else: raise RuntimeError("No LLM clients available") from e + elif provider == "openrouter": + try: + trusted_user_concept_id, trusted_org_concept_id = ( + _resolve_effective_llm_actor_scope( + user_concept_id, + org_concept_id, + ) + ) + openrouter_kwargs = dict(kwargs) + openrouter_kwargs["model_execution_user_concept_id"] = ( + trusted_user_concept_id + ) + openrouter_kwargs["model_execution_org_concept_id"] = ( + trusted_org_concept_id + ) + openrouter_kwargs["model_execution_actor_scope_bound"] = True + return OpenRouterClient(**openrouter_kwargs) + except Exception as e: + safe_error = sanitise_transport_telemetry_text(str(e)) + logger.error("Failed to initialise OpenRouter client: %s", safe_error) + raise RuntimeError( + "OpenRouter was selected, but its client could not be initialised. " + "No cross-provider fallback was attempted." + ) from e + elif provider == "gemini": try: trusted_user_concept_id, trusted_org_concept_id = ( @@ -2943,16 +3288,7 @@ def get_llm_client( ) from e else: - # Invalid provider should raise ValueError instead of defaulting - if client_type: # Only raise if explicitly provided invalid type - raise ValueError(f"Unknown provider '{provider}'") - else: - logger.warning(f"Unknown provider '{provider}'. Defaulting to Ollama.") - default_fb = _ensure_ollama_client() - if default_fb: - return default_fb - else: - raise RuntimeError("No LLM clients available") + raise ValueError(f"Unknown provider '{provider}'") def validate_openai_config( @@ -3023,6 +3359,8 @@ def get_active_model_name( provider = resolved_provider if provider == "openai": return resolve_openai_model_name(model) + if provider == "openrouter": + return resolve_openrouter_model_name(model) if provider == "ollama": return resolve_ollama_model_name(model) return model diff --git a/src/backend/languagemodels/structured_tool_calling/factory.py b/src/backend/languagemodels/structured_tool_calling/factory.py index 6103285e6..5381aa8ce 100644 --- a/src/backend/languagemodels/structured_tool_calling/factory.py +++ b/src/backend/languagemodels/structured_tool_calling/factory.py @@ -28,11 +28,16 @@ def get_llm_client(config: LLMClientConfig) -> LLMClient: if provider == "openai": return OpenAIClient(config) + if provider == "openrouter": + return OpenAIClient(config) if provider == "gemini": return GeminiClient(config) if provider == "ollama": return OllamaClient(config) + if provider: + raise ValueError(f"Unknown provider '{provider}'") + # gpt-oss models are local (Ollama) despite the "gpt-" prefix if "gpt-oss" in model_lower: return OllamaClient(config) diff --git a/src/backend/languagemodels/structured_tool_calling/providers/openai_client.py b/src/backend/languagemodels/structured_tool_calling/providers/openai_client.py index e426eb15a..9752460f0 100644 --- a/src/backend/languagemodels/structured_tool_calling/providers/openai_client.py +++ b/src/backend/languagemodels/structured_tool_calling/providers/openai_client.py @@ -46,13 +46,19 @@ ) from ....integrations.internal_mcp.tool_call_contracts import validation_diagnostic from ....services.model_parameter_service import ( - openai_chat_completions_kwargs_from_model_parameters, + chat_completions_kwargs_from_model_parameters, openai_responses_kwargs_from_model_parameters, ) logger = logging.getLogger(__name__) +_OPENROUTER_REQUIRED_PROVIDER_PREFERENCES: dict[str, Any] = { + "zdr": True, + "data_collection": "deny", + "require_parameters": True, +} + def _value(item: Any, key: str, default: Any = None) -> Any: if isinstance(item, Mapping): @@ -186,6 +192,9 @@ async def generate_with_tools( allow_advertised_surface_override=continuation is not None, ) decision_telemetry = decision.to_telemetry() + provider_label = ( + "OpenRouter" if decision.provider == "openrouter" else "OpenAI" + ) if decision.status != "compatible": raise UnsupportedStructuredToolTransportError( "No represented API profile supports structured tools for " @@ -229,7 +238,7 @@ async def _invoke( request_client=request_client, ) observe_request_advisory( - provider="openai", + provider=decision.provider, advisory_seconds=request_advisory_seconds, started_monotonic=request_started_monotonic, event_logger=self.logger, @@ -242,10 +251,12 @@ async def _invoke( raise except Exception as exc: safe_error = self._sanitise_provider_error(exc) - self.logger.error("OpenAI structured-tool API error: %s", safe_error) + self.logger.error( + "%s structured-tool API error: %s", provider_label, safe_error + ) if self._has_exact_context_length_exceeded_code(exc): raise StructuredToolContextLimitError( - "OpenAI rejected the structured-tool request because its " + f"{provider_label} rejected the structured-tool request because its " f"context exceeded the model limit: {safe_error}", decision={ **decision.to_telemetry(), @@ -255,7 +266,7 @@ async def _invoke( ) from exc if self._looks_like_tool_call_lineage_rejection(exc): raise StructuredToolProtocolError( - "OpenAI rejected a function-call output whose provider call " + f"{provider_label} rejected a function-call output whose provider call " "lineage was unavailable; refusing an uncorrelated retry: " f"{safe_error}", decision={ @@ -283,7 +294,7 @@ async def _invoke( ), } raise StructuredToolCapabilityRejectedError( - "OpenAI rejected the represented continuation surface; " + f"{provider_label} rejected the represented continuation surface; " "cross-surface fallback is not valid mid-continuation: " f"{safe_error}", decision=continuation_rejection, @@ -327,7 +338,7 @@ async def _invoke( alternate_exc ): raise StructuredToolContextLimitError( - "OpenAI rejected the advertised alternate " + f"{provider_label} rejected the advertised alternate " "structured-tool surface because its context " f"exceeded the model limit: {alternate_safe_error}", decision={ @@ -344,7 +355,7 @@ async def _invoke( ) from alternate_exc if self._looks_like_tool_call_lineage_rejection(alternate_exc): raise StructuredToolProtocolError( - "OpenAI rejected a function-call output whose " + f"{provider_label} rejected a function-call output whose " "provider call lineage was unavailable on the " "advertised alternate surface; refusing an " "uncorrelated retry: " @@ -379,7 +390,7 @@ async def _invoke( ], } raise StructuredToolCapabilityRejectedError( - "OpenAI rejected structured tools on both represented " + f"{provider_label} rejected structured tools on both represented " f"surfaces; final surface {alternate_surface}: " f"{alternate_safe_error}", decision=rejection_telemetry, @@ -430,11 +441,11 @@ async def _invoke( **self._provider_error_metadata(exc), } raise StructuredToolCapabilityRejectedError( - f"OpenAI rejected structured tools on " + f"{provider_label} rejected structured tools on " f"{decision.effective_api_surface}: {safe_error}", decision=rejection_telemetry, ) from exc - raise ToolCallError(f"OpenAI call failed: {safe_error}") from exc + raise ToolCallError(f"{provider_label} call failed: {safe_error}") from exc def _resolved_connection_id(self) -> str: configured = str(self.config.connection_id or "").strip() @@ -474,6 +485,28 @@ async def _generate_chat_completions( tool_results=tool_results, ) tools = [self._tool_definition_to_dict(tool) for tool in available_tools] + if decision.provider == "openrouter": + raw_extra_body = request_kwargs.get("extra_body") + extra_body = ( + dict(raw_extra_body) if isinstance(raw_extra_body, Mapping) else {} + ) + raw_provider_preferences = extra_body.get("provider") + provider_preferences = ( + dict(raw_provider_preferences) + if isinstance(raw_provider_preferences, Mapping) + else {} + ) + provider_preferences.update(_OPENROUTER_REQUIRED_PROVIDER_PREFERENCES) + extra_body["provider"] = provider_preferences + request_kwargs["extra_body"] = extra_body + raw_extra_headers = request_kwargs.get("extra_headers") + extra_headers = ( + dict(raw_extra_headers) + if isinstance(raw_extra_headers, Mapping) + else {} + ) + extra_headers["X-OpenRouter-Metadata"] = "enabled" + request_kwargs["extra_headers"] = extra_headers requested_parameters = ( dict(llm_params) if isinstance(llm_params, Mapping) else {} ) @@ -486,8 +519,9 @@ async def _generate_chat_completions( requested_parameters["reasoning_effort"] = direct_reasoning_effort _merge_provider_parameter_kwargs( effective_parameters, - openai_chat_completions_kwargs_from_model_parameters( + chat_completions_kwargs_from_model_parameters( {"reasoning_effort": direct_reasoning_effort}, + provider=decision.provider, model=request_model, profile_concept_id=decision.profile_concept_id, ), @@ -498,8 +532,9 @@ async def _generate_chat_completions( if isinstance(llm_params, Mapping) and llm_params: _merge_provider_parameter_kwargs( effective_parameters, - openai_chat_completions_kwargs_from_model_parameters( + chat_completions_kwargs_from_model_parameters( llm_params, + provider=decision.provider, model=request_model, profile_concept_id=decision.profile_concept_id, ), @@ -555,6 +590,13 @@ async def _generate_chat_completions( continuation=continuation, ) ) + if decision.provider == "openrouter": + parsed.transport_metadata["provider_routing_preferences"] = ( + sanitise_transport_telemetry_value( + request_kwargs["extra_body"]["provider"] + ) + ) + parsed.transport_metadata["router_metadata_requested"] = True if continuation is not None: retained_count = ( len(continuation.input_items) @@ -1351,6 +1393,20 @@ def _parse_chat_response( } ) transport_metadata = decision.to_telemetry() + if decision.provider == "openrouter": + generation_id = _value(response, "id") + if generation_id: + transport_metadata["generation_id"] = ( + sanitise_transport_telemetry_value(generation_id) + ) + router_metadata = _value(response, "openrouter_metadata") + if router_metadata is not None: + transport_metadata["openrouter_metadata"] = ( + sanitise_transport_telemetry_value( + _provider_item_mapping(router_metadata) or router_metadata, + max_depth=6, + ) + ) service_tier = _value(response, "service_tier") if isinstance(service_tier, str) and service_tier.strip(): transport_metadata["effective_service_tier"] = service_tier.strip().lower() @@ -1744,6 +1800,16 @@ def _usage_mapping(usage: Any, *, responses: bool) -> Optional[Dict[str, Any]]: cache_write_input_tokens = _value(input_details, "cache_write_tokens") if cache_write_input_tokens is not None: usage_payload["cache_write_input_tokens"] = cache_write_input_tokens + provider_cost = _value(usage, "cost") + if provider_cost is not None: + usage_payload["provider_reported_cost"] = provider_cost + cost_details = _value(usage, "cost_details") + if cost_details is not None: + usage_payload["provider_reported_cost_details"] = ( + sanitise_transport_telemetry_value( + _provider_item_mapping(cost_details) or cost_details + ) + ) return usage_payload @staticmethod diff --git a/src/backend/languagemodels/structured_tool_calling/transport.py b/src/backend/languagemodels/structured_tool_calling/transport.py index 8eff1c5f5..9a87eead6 100644 --- a/src/backend/languagemodels/structured_tool_calling/transport.py +++ b/src/backend/languagemodels/structured_tool_calling/transport.py @@ -77,11 +77,11 @@ def sanitise_transport_telemetry_text(value: str) -> str: def sanitise_transport_telemetry_value( - value: Any, *, key: str = "", depth: int = 0 + value: Any, *, key: str = "", depth: int = 0, max_depth: int = 3 ) -> Any: if _SENSITIVE_TELEMETRY_KEY_RE.search(key): return "[redacted]" - if depth >= 3: + if depth >= max_depth: return "[bounded]" if isinstance(value, Mapping): return { @@ -89,12 +89,18 @@ def sanitise_transport_telemetry_value( item_value, key=str(item_key), depth=depth + 1, + max_depth=max_depth, ) for item_key, item_value in list(value.items())[:40] } if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): return [ - sanitise_transport_telemetry_value(item, key=key, depth=depth + 1) + sanitise_transport_telemetry_value( + item, + key=key, + depth=depth + 1, + max_depth=max_depth, + ) for item in list(value)[:40] ] if isinstance(value, str): @@ -334,7 +340,7 @@ def resolve_structured_tool_transport( requested_surface = _normalise(requested_api_surface) or "auto" projection = dict(parameter_projection or {}) - if provider_key != "openai": + if provider_key not in {"openai", "openrouter"}: return StructuredToolTransportDecision( provider=provider_key, model=model_name, @@ -492,6 +498,30 @@ def resolve_structured_tool_transport( ) required_surfaces = {item[1] for item in advertised if item[2] == "required"} + if provider_key == "openrouter": + non_chat_required_surfaces = required_surfaces - { + API_SURFACE_CHAT_COMPLETIONS + } + if non_chat_required_surfaces: + return StructuredToolTransportDecision( + **common, + effective_api_surface=sorted(non_chat_required_surfaces)[0], + status="unsupported", + reason="openrouter_chat_completions_only", + capability_class="provider_surface_not_enabled", + capability_source=str( + provenance.get("source") or "model_registry" + ), + advertised_alternatives=(), + ) + # OpenRouter's Responses-compatible beta is outside this bounded + # release. Non-required advertisements must not opt a call into it. + advertised = [ + item + for item in advertised + if item[1] == API_SURFACE_CHAT_COMPLETIONS + ] + # Contradictory represented authority is invalid independently of whether # this particular request carries tools. Answer-only traffic must not # silently choose one side of a same-surface contradiction. diff --git a/src/backend/server/routes/settings_routes.py b/src/backend/server/routes/settings_routes.py index 6f54ecebe..0fbe58cd5 100644 --- a/src/backend/server/routes/settings_routes.py +++ b/src/backend/server/routes/settings_routes.py @@ -104,6 +104,7 @@ GeminiClient, ModelExecutionEligibilityError, OpenAIClient, + OpenRouterClient, assert_model_execution_allowed, get_ollama_auto_pull_state_snapshot, resolve_openai_responses_max_output_tokens, @@ -804,7 +805,7 @@ def get_llm_info(): # overrides the DB-stored setting (e.g. premium disabled, Ollama selected locally). # Only known providers are accepted; any other value is ignored. effective_provider_override = request.args.get("effective_provider") - if effective_provider_override in ("openai", "gemini", "ollama"): + if effective_provider_override in ("openai", "openrouter", "gemini", "ollama"): provider = effective_provider_override effective_model_override = str( request.args.get("effective_model") or "" @@ -846,6 +847,24 @@ def get_llm_info(): status = "error" error_message = str(e) + elif provider == "openrouter": + api_key = _resolve_settings_api_key("OPENROUTER_API_KEY") + if not api_key: + status = "missing_key" + error_message = "OpenRouter API key not set" + else: + try: + OpenRouterClient(api_key=api_key).list_models() + status = "ready" + ping_ok = True + except Exception as e: + status = "error" + error_message = "OpenRouter status check failed." + current_app.logger.warning( + "OpenRouter LLM status check failed (error_type=%s)", + type(e).__name__, + ) + elif provider == "gemini": api_key = _resolve_settings_api_key( "GEMINI_API_KEY", @@ -1039,6 +1058,7 @@ def get_all_settings_data(): return { **db_settings, # active_llm, openai_api_key_env_var, fetch_counts_on_load, etc. "gemini_api_key_env_var": "GEMINI_API_KEY", + "openrouter_api_key_env_var": "OPENROUTER_API_KEY", "buttonify_prompt_ids": list(BUTTONIFY_PROMPT_IDS), "buttonify_prompt_active": ( BUTTONIFY_PROMPT_IDS[0] if BUTTONIFY_PROMPT_IDS else None @@ -1918,6 +1938,8 @@ def _normalise_settings_key_check_env_var(value: object) -> str | None: """Accept only key names selected by server-side model configuration.""" name = str(value or "").strip() + if name == "OPENROUTER_API_KEY": + return name if name in _GEMINI_SETTINGS_KEY_ENV_VARS: return name try: @@ -2081,6 +2103,36 @@ def get_gemini_models(): ) +@settings_bp.route("/models/openrouter", methods=["GET"]) +def get_openrouter_models(): + """Return the current OpenRouter catalogue without changing eligibility.""" + + api_key = _resolve_settings_api_key("OPENROUTER_API_KEY") + if not api_key: + return _jsonify_no_store( + {"error": "OpenRouter API key was not found in OPENROUTER_API_KEY."}, + 400, + ) + try: + models = OpenRouterClient(api_key=api_key).list_models() + if not models: + return _jsonify_no_store( + {"error": "OpenRouter returned no models for the configured key."}, + 400, + ) + current_app.logger.info("Retrieved %s OpenRouter models", len(models)) + return _jsonify_no_store(models, 200) + except Exception as exc: + current_app.logger.warning( + "Could not list OpenRouter models (error_type=%s)", + type(exc).__name__, + ) + return _jsonify_no_store( + {"error": "Could not retrieve OpenRouter models with the configured key."}, + 400, + ) + + @settings_bp.route("/model_parameters/capabilities", methods=["GET"]) def get_model_parameter_capabilities(): provider = str(request.args.get("provider") or "").strip().lower() @@ -2541,6 +2593,47 @@ def verify_gemini_api_key(): ) +@settings_bp.route("/openrouter/verify", methods=["POST"]) +def verify_openrouter_api_key(): + """Verify the fixed OpenRouter key source by listing its current catalogue.""" + + payload = request.get_json(silent=True) or {} + if str(payload.get("api_key_env_var") or "OPENROUTER_API_KEY").strip() != "OPENROUTER_API_KEY": + return _jsonify_no_store( + { + "success": False, + "error": "OpenRouter key source must be OPENROUTER_API_KEY.", + }, + 400, + ) + api_key = _resolve_settings_api_key("OPENROUTER_API_KEY") + if not api_key: + return _jsonify_no_store( + { + "success": False, + "error": "OpenRouter API key was not found in OPENROUTER_API_KEY.", + }, + 400, + ) + try: + models = OpenRouterClient(api_key=api_key).list_models() + if not models: + raise RuntimeError("empty model catalogue") + return _jsonify_no_store({"success": True, "models": models}, 200) + except Exception as exc: + current_app.logger.warning( + "OpenRouter API key verification failed (error_type=%s)", + type(exc).__name__, + ) + return _jsonify_no_store( + { + "success": False, + "error": "OpenRouter API verification failed with the configured key.", + }, + 400, + ) + + @settings_bp.route("/ollama/hosts", methods=["GET"]) def get_ollama_hosts(): """API endpoint to get all configured Ollama hosts.""" @@ -4157,6 +4250,150 @@ def test_gemini_model(): ) +@settings_bp.route("/openrouter/test_model", methods=["POST"]) +def test_openrouter_model(): + """Probe an already-allowed OpenRouter model with the conservative profile.""" + + payload = request.get_json(silent=True) or {} + api_key_env_var = str( + payload.get("api_key_env_var") or "OPENROUTER_API_KEY" + ).strip() + requested_model = str(payload.get("model") or "").strip() + if api_key_env_var != "OPENROUTER_API_KEY": + return _jsonify_no_store( + { + "success": False, + "usable": False, + "failure_kind": "invalid_key_env_var", + "reason": "OpenRouter key source must be OPENROUTER_API_KEY.", + }, + 400, + ) + if not requested_model: + return _jsonify_no_store( + { + "success": False, + "usable": False, + "failure_kind": "missing_model", + "reason": "No OpenRouter model is selected.", + }, + 400, + ) + try: + assert_model_execution_allowed(provider="openrouter", model=requested_model) + except ModelExecutionEligibilityError as exc: + return _jsonify_no_store( + { + "success": False, + "usable": False, + "model": exc.model or requested_model, + "failure_kind": exc.failure_kind, + "reason": str(exc), + }, + 403, + ) + + api_key = _resolve_settings_api_key("OPENROUTER_API_KEY") + if not api_key: + return _jsonify_no_store( + { + "success": False, + "usable": False, + "model": requested_model, + "failure_kind": "missing_api_key", + "reason": "OpenRouter API key was not found in OPENROUTER_API_KEY.", + }, + 400, + ) + model_parameters = normalise_model_parameters_for_storage( + payload.get(MODEL_PARAMETERS_KEY) or payload.get("modelParameters"), + provider="openrouter", + model=requested_model, + api_surface="chat_completions", + include_registry=True, + ) + parameter_capabilities = build_model_parameter_capabilities( + provider="openrouter", + model=requested_model, + api_surface="chat_completions", + include_registry=True, + ) + try: + client = OpenRouterClient(api_key=api_key) + available_models = client.list_models() + if available_models and requested_model not in available_models: + return _jsonify_no_store( + { + "success": True, + "usable": False, + "model": requested_model, + MODEL_PARAMETERS_KEY: model_parameters, + "model_parameter_capabilities": parameter_capabilities, + "failure_kind": "model_unavailable", + "reason": ( + f"{requested_model} is not present in the current " + "OpenRouter model catalogue." + ), + } + ) + client.generate( + "Reply exactly with OK.", + model=requested_model, + llm_params=model_parameters or None, + ) + return _jsonify_no_store( + { + "success": True, + "usable": True, + "model": requested_model, + MODEL_PARAMETERS_KEY: model_parameters, + "model_parameter_capabilities": parameter_capabilities, + "failure_kind": None, + "reason": ( + "The selected OpenRouter model completed a live " + "privacy-constrained probe successfully." + ), + "api_surface": "chat_completions", + "privacy_profile": { + "zdr": True, + "data_collection": "deny", + "require_parameters": True, + }, + } + ) + except Exception as exc: + status_code = getattr(exc, "status_code", None) + lowered = str(exc).lower() + failure_kind = "unexpected_error" + reason = "Unexpected OpenRouter model probe failure." + if status_code == 401 or "auth" in lowered or "api key" in lowered: + failure_kind, reason = "authentication_error", "OpenRouter authentication failed." + elif status_code == 403 or "permission" in lowered: + failure_kind, reason = "permission_denied", "OpenRouter access was denied for this model." + elif status_code == 404 or "not found" in lowered: + failure_kind, reason = "model_not_found", "The selected OpenRouter model was not found." + elif status_code == 429 or "rate limit" in lowered or "quota" in lowered: + failure_kind, reason = "rate_limited", "OpenRouter rate or quota limit was reached." + elif "connect" in lowered or "network" in lowered: + failure_kind, reason = "connection_error", "Could not reach OpenRouter." + current_app.logger.warning( + "[openrouter_test_model] Probe failed for %s (error_type=%s)", + requested_model, + type(exc).__name__, + ) + return _jsonify_no_store( + { + "success": True, + "usable": False, + "model": requested_model, + MODEL_PARAMETERS_KEY: model_parameters, + "model_parameter_capabilities": parameter_capabilities, + "failure_kind": failure_kind, + "reason": reason, + } + ) + + @settings_bp.route("/ollama/test_model", methods=["POST"]) def test_ollama_model(): """Probe whether a selected Ollama model is reachable and usable.""" diff --git a/src/backend/server/routes/von_routes.py b/src/backend/server/routes/von_routes.py index 2a2c4c2a1..c0f8ce3c5 100644 --- a/src/backend/server/routes/von_routes.py +++ b/src/backend/server/routes/von_routes.py @@ -6523,7 +6523,7 @@ def _resolve_generate_requested_model( if isinstance(provider_value, str) and provider_value.strip(): requested_provider_name = provider_value.strip().lower() break - if requested_provider_name not in {"openai", "ollama", "gemini"}: + if requested_provider_name not in {"openai", "openrouter", "ollama", "gemini"}: requested_provider_name = None explicit_client_type = None @@ -6564,7 +6564,7 @@ def _resolve_generate_requested_model( active_provider = ( str(resolved_setting.get("provider") or "").strip().lower() ) - if active_provider in {"openai", "ollama", "gemini"}: + if active_provider in {"openai", "openrouter", "ollama", "gemini"}: explicit_client_type = active_provider model_name = get_active_model_name( user_concept_id=user_concept_id, diff --git a/src/backend/services/adaptive_turn_service.py b/src/backend/services/adaptive_turn_service.py index 03c855b2f..00b35c7e6 100644 --- a/src/backend/services/adaptive_turn_service.py +++ b/src/backend/services/adaptive_turn_service.py @@ -6480,7 +6480,7 @@ def execute_adaptive_turn( configured_provider = next( ( provider_name - for provider_name in ("openai", "gemini", "ollama") + for provider_name in ("openai", "openrouter", "gemini", "ollama") if provider_name in client_name ), "", diff --git a/src/backend/services/llm_api_key_resolution.py b/src/backend/services/llm_api_key_resolution.py index 4af61caac..3b26d1d17 100644 --- a/src/backend/services/llm_api_key_resolution.py +++ b/src/backend/services/llm_api_key_resolution.py @@ -27,3 +27,9 @@ def get_gemini_api_key() -> str | None: """ return _first_nonempty_env_value(_GEMINI_API_KEY_ENV_VARS) + + +def get_openrouter_api_key() -> str | None: + """Return the configured OpenRouter key from env or an env-selected file.""" + + return _first_nonempty_env_value(("OPENROUTER_API_KEY",)) diff --git a/src/backend/services/model_parameter_service.py b/src/backend/services/model_parameter_service.py index 0f48059c0..ab7c73c58 100644 --- a/src/backend/services/model_parameter_service.py +++ b/src/backend/services/model_parameter_service.py @@ -507,6 +507,25 @@ def openai_chat_completions_kwargs_from_model_parameters( ) +def chat_completions_kwargs_from_model_parameters( + raw: Any, + *, + provider: str, + model: str | None, + profile_concept_id: str | None = None, +) -> dict[str, Any]: + """Project neutral parameters onto an OpenAI-compatible Chat surface.""" + + return provider_kwargs_from_model_parameters( + raw, + provider=provider, + model=model, + api_surface="chat_completions", + include_registry=True, + profile_concept_id=profile_concept_id, + ) + + def gemini_kwargs_from_model_parameters( raw: Any, *, diff --git a/src/backend/services/model_registry_service.py b/src/backend/services/model_registry_service.py index 06651067f..726b45897 100644 --- a/src/backend/services/model_registry_service.py +++ b/src/backend/services/model_registry_service.py @@ -49,7 +49,7 @@ DEFAULT_MINIMUM_REPLAY_CASES_FOR_CERTIFICATION = 2 KNOWN_PROVIDER_PREFIXES = frozenset( - {"openai", "anthropic", "gemini", "ollama", "deepseek"} + {"openai", "openrouter", "anthropic", "gemini", "ollama", "deepseek"} ) PARAMETER_ACTION_OMIT = "omit" PARAMETER_ACTION_FIXED_VALUE = "fixed_value" diff --git a/src/backend/workflows/model_execution_budget_policy.py b/src/backend/workflows/model_execution_budget_policy.py index 87a9a5f6b..de15699ac 100644 --- a/src/backend/workflows/model_execution_budget_policy.py +++ b/src/backend/workflows/model_execution_budget_policy.py @@ -79,7 +79,7 @@ def _split_provider_from_model(model: str) -> tuple[str | None, str]: return None, cleaned provider, remainder = cleaned.split(":", 1) provider_token = provider.strip().lower() - if provider_token in {"openai", "anthropic", "gemini", "ollama", "deepseek"}: + if provider_token in {"openai", "openrouter", "anthropic", "gemini", "ollama", "deepseek"}: return provider_token, remainder.strip() return None, cleaned diff --git a/src/backend/workflows/prompt_metadata_resolution.py b/src/backend/workflows/prompt_metadata_resolution.py index 65a93ca40..3b95e864d 100644 --- a/src/backend/workflows/prompt_metadata_resolution.py +++ b/src/backend/workflows/prompt_metadata_resolution.py @@ -516,7 +516,7 @@ def _provider_from_model_token(model: str) -> tuple[str | None, str]: return None, cleaned provider, remainder = cleaned.split(":", 1) provider_token = _normalise_match_token(provider) - if provider_token in {"openai", "anthropic", "gemini", "ollama", "deepseek"}: + if provider_token in {"openai", "openrouter", "anthropic", "gemini", "ollama", "deepseek"}: return provider_token, remainder.strip() return None, cleaned diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index 47beacc34..f767562e1 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -333,13 +333,13 @@ function normaliseLlmExecutionContextString(value) { function normaliseLlmExecutionContextModel(value) { const clean = normaliseLlmExecutionContextString(value); if (!clean) return null; - return clean.replace(/^(?:openai|ollama|gemini)[/:]/i, ''); + return clean.replace(/^(?:openai|openrouter|ollama|gemini)[/:]/i, ''); } function inferLlmProviderFromModelReference(value) { const clean = normaliseLlmExecutionContextString(value); if (!clean) return null; - const matched = clean.match(/^(openai|ollama|gemini)[/:]/i); + const matched = clean.match(/^(openai|openrouter|ollama|gemini)[/:]/i); return matched ? matched[1].toLowerCase() : null; } diff --git a/src/frontend/web/von_interface/static/js/domUtils.js b/src/frontend/web/von_interface/static/js/domUtils.js index 2a231f425..60493331f 100644 --- a/src/frontend/web/von_interface/static/js/domUtils.js +++ b/src/frontend/web/von_interface/static/js/domUtils.js @@ -654,7 +654,7 @@ function normaliseModelNameForComparison(name) { if (!name || typeof name !== 'string') return ''; return name .trim() - .replace(/^(?:openai|ollama|gemini)[/:]/i, '') + .replace(/^(?:openai|openrouter|ollama|gemini)[/:]/i, '') .replace(/-\d{4}-\d{2}-\d{2}$/, '') .toLowerCase(); } diff --git a/src/frontend/web/von_interface/static/js/settings.js b/src/frontend/web/von_interface/static/js/settings.js index f06176cb6..ed62969bf 100644 --- a/src/frontend/web/von_interface/static/js/settings.js +++ b/src/frontend/web/von_interface/static/js/settings.js @@ -91,8 +91,15 @@ export async function loadAvailableGeminiModels() { return data; } +export async function loadAvailableOpenRouterModels() { + const { data } = await getJsonDetailed('/api/settings/models/openrouter'); + return data; +} + function premiumProviderLabel(provider) { - return provider === 'gemini' ? 'Gemini' : 'OpenAI'; + if (provider === 'gemini') return 'Gemini'; + if (provider === 'openrouter') return 'OpenRouter'; + return 'OpenAI'; } function renderPremiumModelSelect(select, provider, models = [], selectedModel = null) { @@ -141,6 +148,12 @@ export function renderGeminiModelOptions(selectElementId, models = [], selectedM renderPremiumModelSelect(select, 'gemini', models, selectedModel); } +export function renderOpenRouterModelOptions(selectElementId, models = [], selectedModel = null) { + const select = document.getElementById(selectElementId); + if (!select) return; + renderPremiumModelSelect(select, 'openrouter', models, selectedModel); +} + export async function loadAvailablePeople() { const { data } = await getJsonDetailed('/api/settings/people'); return data; @@ -341,6 +354,24 @@ export async function populateGeminiModelDropdown(selectElementId, selectedModel } } +export async function populateOpenRouterModelDropdown(selectElementId, selectedModel = null) { + const select = document.getElementById(selectElementId); + if (!select) return; + + try { + const models = await loadAvailableOpenRouterModels(); + renderPremiumModelSelect(select, 'openrouter', models, selectedModel); + clearSelectRetryState(select); + } catch (err) { + console.error('Error populating OpenRouter model dropdown:', err); + renderRetryableSelectFailure(select, { + error: err, + fallbackMessage: 'OpenRouter models are temporarily unavailable.', + retryAction: () => populateOpenRouterModelDropdown(selectElementId, selectedModel) + }); + } +} + export async function populatePeopleDropdown(selectElementId, selectedPersonId = null) { const select = document.getElementById(selectElementId); if (!select) return; diff --git a/src/frontend/web/von_interface/static/js/settingsPage.js b/src/frontend/web/von_interface/static/js/settingsPage.js index 5c64147fe..332841ba2 100644 --- a/src/frontend/web/von_interface/static/js/settingsPage.js +++ b/src/frontend/web/von_interface/static/js/settingsPage.js @@ -17,8 +17,10 @@ import { loadOllamaHosts, populateModelDropdown, populateGeminiModelDropdown, + populateOpenRouterModelDropdown, populateOpenAIModelDropdown, renderGeminiModelOptions, + renderOpenRouterModelOptions, renderOpenAIModelOptions, populateOrganisationsDropdown, populatePeopleDropdown, @@ -63,6 +65,8 @@ import { getEffectiveLocalModelPreference, getStoredOpenAiModelParameters, getStoredOpenAiSelectedModel, + getStoredOpenRouterModelParameters, + getStoredOpenRouterSelectedModel, normaliseModelParameters, normaliseLocalModelName, setLocalPremiumModelUseEnabled, @@ -71,6 +75,8 @@ import { setStoredOllamaSelection, setStoredOpenAiModelParameters, setStoredOpenAiSelectedModel, + setStoredOpenRouterModelParameters, + setStoredOpenRouterSelectedModel, setStoredPremiumModelProvider, } from './utils/localModelPreferences.js'; import { @@ -147,6 +153,8 @@ let latestOpenAiModelProbe = null; let latestOpenAiModelParameterCapability = null; let latestGeminiModelProbe = null; let latestGeminiModelParameterCapability = null; +let latestOpenRouterModelProbe = null; +let latestOpenRouterModelParameterCapability = null; let openAiModelCostSummaryGeneration = 0; let latestOllamaModelProbe = null; let latestSettingsAuthStatus = null; @@ -366,6 +374,22 @@ const PREMIUM_PROVIDER_CONFIG = Object.freeze({ testEndpoint: '/api/settings/gemini/test_model', apiSurface: 'interactions', }), + openrouter: Object.freeze({ + label: 'OpenRouter', + envInputId: 'openrouterApiKeyEnvVar', + verifyButtonId: 'verifyOpenRouterApiKeyButton', + providerContainerId: 'openrouterProviderSettings', + modelsContainerId: 'openrouterModelsContainer', + modelSelectId: 'openrouterModelSelect', + testButtonId: 'testOpenRouterModelButton', + addButtonId: 'addOpenRouterToWorkflowPoolButton', + eligibilityStatusId: 'openrouterModelEligibilityStatus', + modelStatusId: 'openrouterModelStatusMessage', + providerStatusId: 'openrouterStatusMessage', + verifyEndpoint: '/api/settings/openrouter/verify', + testEndpoint: '/api/settings/openrouter/test_model', + apiSurface: 'chat_completions', + }), }); function normalisePremiumProvider(value) { @@ -382,12 +406,42 @@ function selectedPremiumProvider() { || 'openai'; } +function getStoredPremiumSelectedModel(provider) { + if (provider === 'gemini') return getStoredGeminiSelectedModel(); + if (provider === 'openrouter') return getStoredOpenRouterSelectedModel(); + return getStoredOpenAiSelectedModel(); +} + +function setStoredPremiumSelectedModel(provider, model) { + if (provider === 'gemini') setStoredGeminiSelectedModel(model); + else if (provider === 'openrouter') setStoredOpenRouterSelectedModel(model); + else setStoredOpenAiSelectedModel(model); +} + +function renderPremiumModelOptions(provider, selectId, models, selectedModel) { + if (provider === 'gemini') { + renderGeminiModelOptions(selectId, models, selectedModel); + } else if (provider === 'openrouter') { + renderOpenRouterModelOptions(selectId, models, selectedModel); + } else { + renderOpenAIModelOptions(selectId, models, selectedModel); + } +} + +async function populatePremiumModelDropdown(provider, selectId, selectedModel) { + if (provider === 'gemini') { + return populateGeminiModelDropdown(selectId, selectedModel); + } + if (provider === 'openrouter') { + return populateOpenRouterModelDropdown(selectId, selectedModel); + } + return populateOpenAIModelDropdown(selectId, selectedModel); +} + function selectedPremiumModelName(provider = selectedPremiumProvider()) { const config = PREMIUM_PROVIDER_CONFIG[provider]; if (!config) return ''; - const storedModel = provider === 'gemini' - ? getStoredGeminiSelectedModel() - : getStoredOpenAiSelectedModel(); + const storedModel = getStoredPremiumSelectedModel(provider); return String(document.getElementById(config.modelSelectId)?.value || storedModel || '').trim(); } @@ -396,7 +450,7 @@ function getSettingsConcernModelSnapshot() { const premiumToggle = document.getElementById('enableOpenAiPremiumToggle'); const premiumEnabled = premiumToggle ? !!premiumToggle.checked - : ['openai', 'gemini'].includes(localModelPreference.activeSource); + : ['openai', 'openrouter', 'gemini'].includes(localModelPreference.activeSource); const premiumProvider = selectedPremiumProvider(); const premiumModel = selectedPremiumModelName(premiumProvider); const ollamaSelection = resolveOllamaSelection(false) || localModelPreference.ollamaSelection || null; @@ -460,7 +514,7 @@ function buildTemporarySettingsConcernRecommendation(concernId) { description: 'Review language and concept-display preferences here, then move on to the model surface this browser should prefer.', actionLabel: 'Review model setup', actionTarget: 'premium-model-settings', - focusSelector: '#globalModelSelect, #premiumProviderSelect, #openaiModelSelect, #geminiModelSelect', + focusSelector: '#globalModelSelect, #premiumProviderSelect, #openaiModelSelect, #openrouterModelSelect, #geminiModelSelect', source: 'temporary-placeholder', }; } @@ -925,6 +979,7 @@ function resolveDisplayedProviderModels(settings) { const enabledLlms = Array.isArray(settings?.enabled_llms) ? settings.enabled_llms : []; let currentOllamaModel = null; let currentOpenAIModel = null; + let currentOpenRouterModel = null; let currentGeminiModel = null; for (const entry of enabledLlms) { @@ -933,6 +988,8 @@ function resolveDisplayedProviderModels(settings) { currentOllamaModel = entry.model; } else if (entry.provider === 'openai' && entry.model && !currentOpenAIModel) { currentOpenAIModel = entry.model; + } else if (entry.provider === 'openrouter' && entry.model && !currentOpenRouterModel) { + currentOpenRouterModel = entry.model; } else if (entry.provider === 'gemini' && entry.model && !currentGeminiModel) { currentGeminiModel = entry.model; } @@ -942,6 +999,8 @@ function resolveDisplayedProviderModels(settings) { currentOllamaModel = effectiveLlm.model; } else if (effectiveLlm?.provider === 'openai' && effectiveLlm.model) { currentOpenAIModel = effectiveLlm.model; + } else if (effectiveLlm?.provider === 'openrouter' && effectiveLlm.model) { + currentOpenRouterModel = effectiveLlm.model; } else if (effectiveLlm?.provider === 'gemini' && effectiveLlm.model) { currentGeminiModel = effectiveLlm.model; } @@ -950,6 +1009,7 @@ function resolveDisplayedProviderModels(settings) { effectiveLlm, currentOllamaModel, currentOpenAIModel, + currentOpenRouterModel, currentGeminiModel, }; } @@ -1141,28 +1201,32 @@ function setInlineStatusMessage(element, text, tone = null) { } function getStoredPremiumModelParameters(provider) { - return provider === 'gemini' - ? getStoredGeminiModelParameters() - : getStoredOpenAiModelParameters(); + if (provider === 'gemini') return getStoredGeminiModelParameters(); + if (provider === 'openrouter') return getStoredOpenRouterModelParameters(); + return getStoredOpenAiModelParameters(); } function setStoredPremiumModelParameters(provider, parameters) { if (provider === 'gemini') { setStoredGeminiModelParameters(parameters); + } else if (provider === 'openrouter') { + setStoredOpenRouterModelParameters(parameters); } else { setStoredOpenAiModelParameters(parameters); } } function getLatestPremiumModelParameterCapability(provider) { - return provider === 'gemini' - ? latestGeminiModelParameterCapability - : latestOpenAiModelParameterCapability; + if (provider === 'gemini') return latestGeminiModelParameterCapability; + if (provider === 'openrouter') return latestOpenRouterModelParameterCapability; + return latestOpenAiModelParameterCapability; } function setLatestPremiumModelParameterCapability(provider, capability) { if (provider === 'gemini') { latestGeminiModelParameterCapability = capability; + } else if (provider === 'openrouter') { + latestOpenRouterModelParameterCapability = capability; } else { latestOpenAiModelParameterCapability = capability; } @@ -1252,12 +1316,16 @@ async function refreshOpenAiReasoningEffortControls() { } function latestPremiumModelProbe(provider) { - return provider === 'gemini' ? latestGeminiModelProbe : latestOpenAiModelProbe; + if (provider === 'gemini') return latestGeminiModelProbe; + if (provider === 'openrouter') return latestOpenRouterModelProbe; + return latestOpenAiModelProbe; } function setLatestPremiumModelProbe(provider, probe) { if (provider === 'gemini') { latestGeminiModelProbe = probe; + } else if (provider === 'openrouter') { + latestOpenRouterModelProbe = probe; } else { latestOpenAiModelProbe = probe; } @@ -1336,8 +1404,9 @@ function updatePremiumModelStatusMessageForProvider(provider) { function updatePremiumModelStatusMessages() { updateSelectedPremiumEligibilityControls(); - updatePremiumModelStatusMessageForProvider('openai'); - updatePremiumModelStatusMessageForProvider('gemini'); + for (const provider of Object.keys(PREMIUM_PROVIDER_CONFIG)) { + updatePremiumModelStatusMessageForProvider(provider); + } } function updateOpenAiModelStatusMessage() { @@ -1424,11 +1493,7 @@ async function handlePremiumModelSelectionChange(provider) { const model = normaliseLocalModelName( document.getElementById(config?.modelSelectId)?.value, ) || ''; - if (provider === 'gemini') { - setStoredGeminiSelectedModel(model); - } else { - setStoredOpenAiSelectedModel(model); - } + setStoredPremiumSelectedModel(provider, model); invalidateSelectedOpenAiCostSummary(); if (selectedPremiumProvider() === provider) { await refreshPremiumReasoningEffortControls(provider); @@ -1494,11 +1559,7 @@ async function testSelectedPremiumModel(provider) { }; } - if (provider === 'gemini') { - setStoredGeminiSelectedModel(selectedModel); - } else { - setStoredOpenAiSelectedModel(selectedModel); - } + setStoredPremiumSelectedModel(provider, selectedModel); setStoredPremiumModelParameters(provider, modelParameters); setInlineStatusMessage(statusEl, `Testing ${config.label} ${selectedModel}...`, null); @@ -1546,6 +1607,10 @@ async function testSelectedGeminiModel() { return testSelectedPremiumModel('gemini'); } +async function testSelectedOpenRouterModel() { + return testSelectedPremiumModel('openrouter'); +} + async function testSelectedOllamaModel() { const selection = resolveOllamaSelection(false); const selectedModel = String(selection?.model || selection?.value || '').trim(); @@ -1946,6 +2011,10 @@ function selectedGeminiPoolEntry() { return selectedPremiumPoolEntry('gemini'); } +function selectedOpenRouterPoolEntry() { + return selectedPremiumPoolEntry('openrouter'); +} + function isPersistedEffectiveModelAllowed(entry) { const canonical = buildCanonicalLlmEntry(entry); return Boolean( @@ -2259,6 +2328,7 @@ function renderWorkflowModelPool() { loading.textContent = 'Scoped model settings are loading. Saving is unavailable until they are ready.'; listEl.appendChild(loading); updatePoolActionButton('addOpenAiToWorkflowPoolButton', selectedOpenAiPoolEntry()); + updatePoolActionButton('addOpenRouterToWorkflowPoolButton', selectedOpenRouterPoolEntry()); updatePoolActionButton('addGeminiToWorkflowPoolButton', selectedGeminiPoolEntry()); updatePoolActionButton('addOllamaToWorkflowPoolButton', selectedOllamaPoolEntry()); updateScopedPrimaryActionButtons(); @@ -2324,6 +2394,7 @@ function renderWorkflowModelPool() { } updatePoolActionButton('addOpenAiToWorkflowPoolButton', selectedOpenAiPoolEntry()); + updatePoolActionButton('addOpenRouterToWorkflowPoolButton', selectedOpenRouterPoolEntry()); updatePoolActionButton('addGeminiToWorkflowPoolButton', selectedGeminiPoolEntry()); updatePoolActionButton('addOllamaToWorkflowPoolButton', selectedOllamaPoolEntry()); updateScopedPrimaryActionButtons(); @@ -4899,6 +4970,9 @@ document.addEventListener('DOMContentLoaded', async () => { document.getElementById('geminiModelSelect')?.addEventListener('change', () => { void handlePremiumModelSelectionChange('gemini'); }); + document.getElementById('openrouterModelSelect')?.addEventListener('change', () => { + void handlePremiumModelSelectionChange('openrouter'); + }); document.getElementById('openaiReasoningEffortSelect')?.addEventListener('change', async () => { const provider = selectedPremiumProvider(); setStoredPremiumModelParameters(provider, readPremiumModelParametersFromUi(provider)); @@ -5042,8 +5116,12 @@ document.getElementById('verifyOpenAiApiKeyButton')?.addEventListener('click', ( document.getElementById('verifyGeminiApiKeyButton')?.addEventListener('click', () => { verifyGeminiApiKey(); }); +document.getElementById('verifyOpenRouterApiKeyButton')?.addEventListener('click', () => { + verifyOpenRouterApiKey(); +}); document.getElementById('testOpenAiModelButton')?.addEventListener('click', testSelectedOpenAiModel); document.getElementById('testGeminiModelButton')?.addEventListener('click', testSelectedGeminiModel); +document.getElementById('testOpenRouterModelButton')?.addEventListener('click', testSelectedOpenRouterModel); document.getElementById('testOllamaModelButton')?.addEventListener('click', testSelectedOllamaModel); document.getElementById('addOpenAiToWorkflowPoolButton')?.addEventListener('click', () => { addOrUpdateWorkflowPoolEntry(selectedOpenAiPoolEntry()); @@ -5051,6 +5129,9 @@ document.getElementById('addOpenAiToWorkflowPoolButton')?.addEventListener('clic document.getElementById('addGeminiToWorkflowPoolButton')?.addEventListener('click', () => { addOrUpdateWorkflowPoolEntry(selectedGeminiPoolEntry()); }); +document.getElementById('addOpenRouterToWorkflowPoolButton')?.addEventListener('click', () => { + addOrUpdateWorkflowPoolEntry(selectedOpenRouterPoolEntry()); +}); document.getElementById('addOllamaToWorkflowPoolButton')?.addEventListener('click', () => { addOrUpdateWorkflowPoolEntry(selectedOllamaPoolEntry()); }); @@ -5370,6 +5451,7 @@ async function loadAndDisplaySettings() { const { effectiveLlm: displayedEffectiveLlm, currentOpenAIModel, + currentOpenRouterModel, currentGeminiModel, } = resolveDisplayedProviderModels(settings); currentEffectiveLlm = settings.effective_llm || settings.resolved_llm || null; @@ -5381,13 +5463,16 @@ async function loadAndDisplaySettings() { const localModelPreference = getEffectiveLocalModelPreference(); const currentOllamaModel = resolveOllamaDropdownSelectionValue(localModelPreference); const preferredOpenAiModel = currentOpenAIModel || localModelPreference.openaiModel || null; + const preferredOpenRouterModel = currentOpenRouterModel || localModelPreference.openrouterModel || null; const preferredGeminiModel = currentGeminiModel || localModelPreference.geminiModel || null; - const premiumEnabled = ['openai', 'gemini'].includes(localModelPreference.activeSource); + const premiumEnabled = ['openai', 'openrouter', 'gemini'].includes(localModelPreference.activeSource); const preferredPremiumProvider = normalisePremiumProvider( localModelPreference.premiumProvider || localModelPreference.activeSource, ) || normalisePremiumProvider(displayedEffectiveLlm?.provider) - || (currentGeminiModel && !currentOpenAIModel ? 'gemini' : 'openai'); + || (currentOpenRouterModel && !currentOpenAIModel + ? 'openrouter' + : currentGeminiModel && !currentOpenAIModel ? 'gemini' : 'openai'); const premiumProviderSelect = document.getElementById('premiumProviderSelect'); if (premiumProviderSelect) premiumProviderSelect.value = preferredPremiumProvider; syncPremiumProviderPanels(); @@ -5426,6 +5511,15 @@ async function loadAndDisplaySettings() { } catch (error) { console.log('Could not directly load Gemini models, will need verification:', error.message); } + } else if (preferredPremiumProvider === 'openrouter' && preferredOpenRouterModel) { + try { + await populateOpenRouterModelDropdown('openrouterModelSelect', preferredOpenRouterModel); + const container = document.getElementById('openrouterModelsContainer'); + if (container) { container.style.display = 'block'; container.classList.remove('hidden'); } + await refreshPremiumReasoningEffortControls('openrouter'); + } catch (error) { + console.log('Could not directly load OpenRouter models, will need verification:', error.message); + } } // Populate People and select the saved one @@ -5575,6 +5669,10 @@ async function loadAndDisplaySettings() { if (geminiEnvVarInput && settings.gemini_api_key_env_var) { geminiEnvVarInput.value = settings.gemini_api_key_env_var; } + const openrouterEnvVarInput = document.getElementById('openrouterApiKeyEnvVar'); + if (openrouterEnvVarInput && settings.openrouter_api_key_env_var) { + openrouterEnvVarInput.value = settings.openrouter_api_key_env_var; + } const openAiPremiumToggle = document.getElementById('enableOpenAiPremiumToggle'); if (openAiPremiumToggle) { openAiPremiumToggle.checked = premiumEnabled; @@ -5585,9 +5683,13 @@ async function loadAndDisplaySettings() { if (preferredGeminiModel) { setStoredGeminiSelectedModel(preferredGeminiModel); } + if (preferredOpenRouterModel) { + setStoredOpenRouterSelectedModel(preferredOpenRouterModel); + } setStoredPremiumModelProvider(preferredPremiumProvider); latestOpenAiModelProbe = null; latestGeminiModelProbe = null; + latestOpenRouterModelProbe = null; latestOllamaModelProbe = null; updateOpenAiModelStatusMessage(); updateOllamaModelStatusMessage(); @@ -5770,7 +5872,11 @@ async function loadAndDisplaySettings() { if (await checkPremiumProviderEnvVar(preferredPremiumProvider)) { await verifyPremiumApiKey( preferredPremiumProvider, - preferredPremiumProvider === 'gemini' ? preferredGeminiModel : preferredOpenAiModel, + preferredPremiumProvider === 'gemini' + ? preferredGeminiModel + : preferredPremiumProvider === 'openrouter' + ? preferredOpenRouterModel + : preferredOpenAiModel, ); } @@ -6205,6 +6311,10 @@ export async function checkGeminiEnvVar() { return checkPremiumProviderEnvVar('gemini'); } +export async function checkOpenRouterEnvVar() { + return checkPremiumProviderEnvVar('openrouter'); +} + async function verifyPremiumApiKey(providerName, savedModel = null) { const provider = normalisePremiumProvider(providerName) || 'openai'; const config = PREMIUM_PROVIDER_CONFIG[provider]; @@ -6212,7 +6322,7 @@ async function verifyPremiumApiKey(providerName, savedModel = null) { const statusMessage = document.getElementById(config.providerStatusId); const modelsContainer = document.getElementById(config.modelsContainerId); const preferredModel = normaliseLocalModelName(savedModel) - || (provider === 'gemini' ? getStoredGeminiSelectedModel() : getStoredOpenAiSelectedModel()) + || getStoredPremiumSelectedModel(provider) || null; setInlineStatusMessage( @@ -6235,20 +6345,17 @@ async function verifyPremiumApiKey(providerName, savedModel = null) { modelsContainer.classList.remove('hidden'); } - if (provider === 'gemini') { - renderGeminiModelOptions(config.modelSelectId, response.models || [], preferredModel); - } else { - renderOpenAIModelOptions(config.modelSelectId, response.models || [], preferredModel); - } + renderPremiumModelOptions( + provider, + config.modelSelectId, + response.models || [], + preferredModel, + ); const selectedModel = normaliseLocalModelName( document.getElementById(config.modelSelectId)?.value, ); if (selectedModel) { - if (provider === 'gemini') { - setStoredGeminiSelectedModel(selectedModel); - } else { - setStoredOpenAiSelectedModel(selectedModel); - } + setStoredPremiumSelectedModel(provider, selectedModel); } if (selectedPremiumProvider() === provider) { await refreshPremiumReasoningEffortControls(provider); @@ -6262,11 +6369,11 @@ async function verifyPremiumApiKey(providerName, savedModel = null) { } catch (error) { // Fall back to the provider's direct model-list endpoint. try { - if (provider === 'gemini') { - await populateGeminiModelDropdown(config.modelSelectId, preferredModel); - } else { - await populateOpenAIModelDropdown(config.modelSelectId, preferredModel); - } + await populatePremiumModelDropdown( + provider, + config.modelSelectId, + preferredModel, + ); setInlineStatusMessage( statusMessage, `Loaded the ${config.label} model list, but live verification failed. Test the selected model before enabling premium use.`, @@ -6299,6 +6406,10 @@ async function verifyGeminiApiKey(savedModel = null) { return verifyPremiumApiKey('gemini', savedModel); } +async function verifyOpenRouterApiKey(savedModel = null) { + return verifyPremiumApiKey('openrouter', savedModel); +} + // Remove the old, separate save functions (saveGlobalModel, saveCurrentUser, saveOpenAISettings) // as they are now replaced by the single saveAllSettings function. diff --git a/src/frontend/web/von_interface/static/js/utils/localModelPreferences.js b/src/frontend/web/von_interface/static/js/utils/localModelPreferences.js index 7b65949ed..068b77f1b 100644 --- a/src/frontend/web/von_interface/static/js/utils/localModelPreferences.js +++ b/src/frontend/web/von_interface/static/js/utils/localModelPreferences.js @@ -6,7 +6,7 @@ const LS_PREMIUM_MODEL_USE_ENABLED = 'von:premiumModelUseEnabled'; const LOCAL_MODEL_PREFERENCE_SCHEMA = 'localModelPreference.v1'; const LOCAL_MODEL_UNAVAILABLE_REASON_NO_OLLAMA_MODEL = 'premium_disabled_no_ollama_model'; const LOCAL_MODEL_UNAVAILABLE_REASON_NO_PREMIUM_MODEL = 'premium_enabled_no_model'; -const PREMIUM_MODEL_PROVIDERS = new Set(['openai', 'gemini']); +const PREMIUM_MODEL_PROVIDERS = new Set(['openai', 'openrouter', 'gemini']); function readStoredJson(key) { try { @@ -37,7 +37,7 @@ function readStoredString(key) { } function normaliseActiveSource(value) { - return value === 'openai' || value === 'gemini' || value === 'ollama' ? value : null; + return PREMIUM_MODEL_PROVIDERS.has(value) || value === 'ollama' ? value : null; } function normalisePremiumProvider(value) { @@ -114,6 +114,8 @@ function buildCanonicalLocalModelPreference({ openaiModelParameters = null, geminiModel = null, geminiModelParameters = null, + openrouterModel = null, + openrouterModelParameters = null, ollamaSelection = null, } = {}) { const normalisedActiveSource = normaliseActiveSource(activeSource); @@ -121,6 +123,7 @@ function buildCanonicalLocalModelPreference({ || (PREMIUM_MODEL_PROVIDERS.has(normalisedActiveSource) ? normalisedActiveSource : null); const normalisedOpenAiParameters = normaliseModelParameters(openaiModelParameters); const normalisedGeminiParameters = normaliseModelParameters(geminiModelParameters); + const normalisedOpenRouterParameters = normaliseModelParameters(openrouterModelParameters); const canonical = { schemaVersion: LOCAL_MODEL_PREFERENCE_SCHEMA, activeSource: normalisedActiveSource, @@ -129,8 +132,8 @@ function buildCanonicalLocalModelPreference({ }; // OpenAI remains the backwards-compatible default premium provider. Persist // the provider only when a different provider must survive an Ollama switch. - if (normalisedPremiumProvider === 'gemini') { - canonical.premiumProvider = 'gemini'; + if (normalisedPremiumProvider && normalisedPremiumProvider !== 'openai') { + canonical.premiumProvider = normalisedPremiumProvider; } const normalisedGeminiModel = normaliseLocalModelName(geminiModel); if (normalisedGeminiModel) { @@ -142,12 +145,20 @@ function buildCanonicalLocalModelPreference({ if (normalisedGeminiParameters) { canonical.geminiModelParameters = normalisedGeminiParameters; } + const normalisedOpenRouterModel = normaliseLocalModelName(openrouterModel); + if (normalisedOpenRouterModel) { + canonical.openrouterModel = normalisedOpenRouterModel; + } + if (normalisedOpenRouterParameters) { + canonical.openrouterModelParameters = normalisedOpenRouterParameters; + } if ( !canonical.activeSource - && normalisedPremiumProvider !== 'gemini' + && (!normalisedPremiumProvider || normalisedPremiumProvider === 'openai') && !canonical.openaiModel && !canonical.geminiModel + && !canonical.openrouterModel && !canonical.ollamaSelection ) { return null; @@ -161,8 +172,9 @@ function cloneLocalModelPreference(preference) { return { schemaVersion: preference.schemaVersion || LOCAL_MODEL_PREFERENCE_SCHEMA, activeSource: preference.activeSource || null, - ...(normalisePremiumProvider(preference.premiumProvider) === 'gemini' - ? { premiumProvider: 'gemini' } + ...(normalisePremiumProvider(preference.premiumProvider) + && normalisePremiumProvider(preference.premiumProvider) !== 'openai' + ? { premiumProvider: normalisePremiumProvider(preference.premiumProvider) } : {}), openaiModel: normaliseLocalModelName(preference.openaiModel), ...(normaliseModelParameters(preference.openaiModelParameters) @@ -174,6 +186,12 @@ function cloneLocalModelPreference(preference) { ...(normaliseModelParameters(preference.geminiModelParameters) ? { geminiModelParameters: normaliseModelParameters(preference.geminiModelParameters) } : {}), + ...(normaliseLocalModelName(preference.openrouterModel) + ? { openrouterModel: normaliseLocalModelName(preference.openrouterModel) } + : {}), + ...(normaliseModelParameters(preference.openrouterModelParameters) + ? { openrouterModelParameters: normaliseModelParameters(preference.openrouterModelParameters) } + : {}), ollamaSelection: normaliseOllamaSelection(preference.ollamaSelection), }; } @@ -197,6 +215,12 @@ function normaliseStoredLocalModelPreference(stored) { ?? stored.gemini_model_parameters ?? null ), + openrouterModel: stored.openrouterModel ?? stored.openrouter_model ?? null, + openrouterModelParameters: ( + stored.openrouterModelParameters + ?? stored.openrouter_model_parameters + ?? null + ), ollamaSelection: stored.ollamaSelection ?? stored.ollama_selection ?? null, }); } @@ -306,6 +330,7 @@ function buildEffectiveLocalModelPreference(preference) { schemaVersion: LOCAL_MODEL_PREFERENCE_SCHEMA, activeSource: null, openaiModel: null, + openrouterModel: null, ollamaSelection: null, requestedLlm: null, modelUnavailable: false, @@ -318,10 +343,16 @@ function buildEffectiveLocalModelPreference(preference) { const provider = canonical.activeSource; requestedLlm = buildPremiumRequestedLlm( provider, - provider === 'gemini' ? canonical.geminiModel : canonical.openaiModel, + provider === 'gemini' + ? canonical.geminiModel + : provider === 'openrouter' + ? canonical.openrouterModel + : canonical.openaiModel, provider === 'gemini' ? canonical.geminiModelParameters - : canonical.openaiModelParameters, + : provider === 'openrouter' + ? canonical.openrouterModelParameters + : canonical.openaiModelParameters, ); } else if (canonical.activeSource === 'ollama') { requestedLlm = buildOllamaRequestedLlm(canonical.ollamaSelection); @@ -402,8 +433,8 @@ export function setStoredPremiumModelProvider(providerName) { openaiModel: null, ollamaSelection: null, }; - if (provider === 'gemini') { - next.premiumProvider = 'gemini'; + if (provider !== 'openai') { + next.premiumProvider = provider; } else { delete next.premiumProvider; } @@ -427,8 +458,8 @@ export function setLocalPremiumModelUseEnabled(enabled, providerName = null) { || normalisePremiumProvider(next.premiumProvider) || (PREMIUM_MODEL_PROVIDERS.has(next.activeSource) ? next.activeSource : null) || 'openai'; - if (provider === 'gemini') { - next.premiumProvider = 'gemini'; + if (provider !== 'openai') { + next.premiumProvider = provider; } else { delete next.premiumProvider; } @@ -517,6 +548,48 @@ export function setStoredGeminiModelParameters(modelParameters) { persistLocalModelPreference(next); } +export function getStoredOpenRouterSelectedModel() { + return getEffectiveLocalModelPreference().openrouterModel || ''; +} + +export function setStoredOpenRouterSelectedModel(modelName) { + const stored = getStoredLocalModelPreference(); + const next = cloneLocalModelPreference(stored) || { + schemaVersion: LOCAL_MODEL_PREFERENCE_SCHEMA, + activeSource: null, + openaiModel: null, + ollamaSelection: null, + }; + const model = normaliseLocalModelName(modelName); + if (model) { + next.openrouterModel = model; + } else { + delete next.openrouterModel; + } + persistLocalModelPreference(next); +} + +export function getStoredOpenRouterModelParameters() { + return getEffectiveLocalModelPreference().openrouterModelParameters || null; +} + +export function setStoredOpenRouterModelParameters(modelParameters) { + const stored = getStoredLocalModelPreference(); + const next = cloneLocalModelPreference(stored) || { + schemaVersion: LOCAL_MODEL_PREFERENCE_SCHEMA, + activeSource: null, + openaiModel: null, + ollamaSelection: null, + }; + const params = normaliseModelParameters(modelParameters); + if (params) { + next.openrouterModelParameters = params; + } else { + delete next.openrouterModelParameters; + } + persistLocalModelPreference(next); +} + export function getStoredOllamaSelection() { return getEffectiveLocalModelPreference().ollamaSelection || null; } diff --git a/src/frontend/web/von_interface/templates/settings_tab.html b/src/frontend/web/von_interface/templates/settings_tab.html index a91c9e79d..7c8eac9a9 100644 --- a/src/frontend/web/von_interface/templates/settings_tab.html +++ b/src/frontend/web/von_interface/templates/settings_tab.html @@ -308,6 +308,7 @@

Premium Models

@@ -362,6 +363,28 @@

Premium Models

+ +