From 5c425aee463d76f21a0e17655f11e6a529716ab2 Mon Sep 17 00:00:00 2001 From: witbrock Date: Thu, 3 Sep 2026 15:28:55 -0400 Subject: [PATCH 1/2] Add Meta Muse premium model provider --- .env.template | 5 + README.md | 10 +- docs/engineering/environment_minimums.md | 10 + infra/openstack/README.md | 4 + .../environments/dev/dev.tfvars.example | 5 + .../environments/prod/prod.tfvars.example | 5 + .../staging/staging.tfvars.example | 5 + infra/openstack/main.tf | 2 + .../cloud-init/von_bootstrap.yaml.tftpl | 11 +- infra/openstack/variables.tf | 27 +- scripts/bootstrap_llm_setting.py | 2 +- .../integrations/internal_mcp/catalogue.py | 1 + .../integrations/internal_mcp/orchestrator.py | 62 ++- src/backend/languagemodels/llm_interface.py | 406 +++++++++++++- src/backend/languagemodels/model_defaults.py | 16 +- .../structured_tool_calling/factory.py | 2 + .../providers/openai_client.py | 142 ++++- .../structured_tool_calling/transport.py | 126 ++++- src/backend/server/routes/settings_routes.py | 363 +++++++++++- src/backend/server/routes/von_routes.py | 18 +- src/backend/services/adaptive_turn_service.py | 8 +- src/backend/services/concept_service.py | 2 +- .../services/llm_api_key_resolution.py | 6 + .../services/model_parameter_service.py | 66 ++- .../services/model_registry_service.py | 2 +- .../workflows/durable/durable_executor.py | 15 +- .../model_execution_budget_policy.py | 10 +- .../workflows/prompt_metadata_resolution.py | 10 +- .../web/von_interface/static/js/chatTab.js | 4 +- .../web/von_interface/static/js/domUtils.js | 2 +- .../web/von_interface/static/js/settings.js | 30 + .../von_interface/static/js/settingsPage.js | 115 +++- .../static/js/utils/localModelPreferences.js | 75 ++- .../von_interface/templates/settings_tab.html | 23 + src/workflows/von/main.py | 2 + tests/backend/test_meta_muse_provider.py | 527 ++++++++++++++++++ tests/backend/test_settings_meta_muse.py | 458 +++++++++++++++ .../backend/test_von_main_dotenv_overrides.py | 7 + tests/frontend/localModelPreferences.test.js | 39 ++ tests/frontend/settingsMetaMuseModels.test.js | 34 ++ .../frontend/settingsMetaMusePremium.test.js | 142 +++++ .../infra/test_openstack_deploy_templates.py | 3 + tests/test_invoke_internal_mcp_tool.py | 2 + utilities/invoke_internal_mcp_tool.py | 2 + 44 files changed, 2741 insertions(+), 65 deletions(-) create mode 100644 tests/backend/test_meta_muse_provider.py create mode 100644 tests/backend/test_settings_meta_muse.py create mode 100644 tests/frontend/settingsMetaMuseModels.test.js create mode 100644 tests/frontend/settingsMetaMusePremium.test.js diff --git a/.env.template b/.env.template index 2e780efc7..1b633a226 100644 --- a/.env.template +++ b/.env.template @@ -90,6 +90,11 @@ MONGO_ALLOW_LOCAL_FALLBACK=0 # Legacy backup aliases: GOOGLE_API_BACKUP_KEY and GOOGLE_API_BACKUP_KEY_FILE # VON_DEFAULT_GEMINI_MODEL=gemini-3.7-flash +# Meta Muse API Key +# Canonical direct value; managed hosts may use META_API_KEY_FILE instead. +# META_API_KEY= +# META_API_KEY_FILE=/etc/von/secrets/meta_api_key + # ============================================================================ # WEB SEARCH (Optional) # ============================================================================ diff --git a/README.md b/README.md index 6ad3ca659..ec19ee3b2 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,8 @@ architecture: - a browser application centred on persistent, multi-turn conversations, with inspectable shared situations, uploads, tasks, tool progress, and workflow state; -- model support for local Ollama and configured OpenAI, Gemini, and opt-in - OpenRouter providers; +- model support for local Ollama, configured OpenAI, and actor-scoped, opt-in + Gemini, Meta Muse, and OpenRouter providers; - an internal Model Context Protocol (MCP) gateway and capability catalogue spanning represented knowledge, retrieval, scholarly sources, web search, tasks, workflows, and configured external services; @@ -190,8 +190,10 @@ Remote database failures do not silently fall back to a possibly stale local database unless that fallback is explicitly enabled. For local models, install and run Ollama and configure `OLLAMA_HOSTS_LIST`. For -hosted providers, set the applicable credential, such as `OPENAI_API_KEY` or -`GEMINI_API_KEY`, then select an eligible provider and model in Von's settings. +hosted providers, set the applicable credential, such as `OPENAI_API_KEY`, +`GEMINI_API_KEY`, or `META_API_KEY`, then select an eligible provider and model +in Von's settings. Meta Muse supports the fixed model `muse-spark-1.3`; managed +hosts may supply its key through `META_API_KEY_FILE`. You may also configure `OPENAI_API_BACKUP_KEY` or `GEMINI_API_BACKUP_KEY` (or their `*_FILE` forms). Von uses a backup once only after the primary credential is rejected for authentication, quota, or rate limiting, and marks the model diff --git a/docs/engineering/environment_minimums.md b/docs/engineering/environment_minimums.md index 77b16fd1b..4ea121bb2 100644 --- a/docs/engineering/environment_minimums.md +++ b/docs/engineering/environment_minimums.md @@ -29,6 +29,8 @@ $env:OPENAI_API_KEY = '' # or $env:GEMINI_API_KEY = '' # or +$env:META_API_KEY = '' +# or $env:OLLAMA_HOST = 'http://:11434' ``` @@ -54,6 +56,14 @@ For legacy Gemini naming, `GOOGLE_API_BACKUP_KEY` and `GOOGLE_API_BACKUP_KEY_FILE` are accepted as aliases. New setups should use the `GEMINI_API_BACKUP_KEY` names. +Meta Muse uses only the canonical `META_API_KEY` credential name. Managed +deployments can instead set +`META_API_KEY_FILE=/etc/von/secrets/meta_api_key`; Von reads the secret from +that file without exposing it through Settings. The supported Meta model is +`muse-spark-1.3`. Catalogue verification does not allow or select the model: +the exact `meta:muse-spark-1.3` pair must first be saved in the actor-scoped +enabled model pool before a paid model probe or generation request. + ## 2. How Von decides whether MongoDB is local or remote Von does not have a separate `local/cloud` switch for MongoDB. It inspects the diff --git a/infra/openstack/README.md b/infra/openstack/README.md index 277fb625b..c3006bbfe 100644 --- a/infra/openstack/README.md +++ b/infra/openstack/README.md @@ -192,6 +192,9 @@ needed for real chat/workflow execution: - `bootstrap_gemini_api_key_file` defaults to `/etc/von/secrets/gemini_api_key`. Gemini keys can be injected with `TF_VAR_bootstrap_gemini_api_key` or pre-provisioned at that path. +- `bootstrap_meta_api_key_file` defaults to `/etc/von/secrets/meta_api_key`. + Meta Model API keys can be injected with `TF_VAR_bootstrap_meta_api_key` or + pre-provisioned at that path. - `bootstrap_default_llm_provider`, `bootstrap_default_llm_model`, and either `bootstrap_default_llm_organisation_concept_id` or `bootstrap_default_llm_user_concept_id` can seed the scoped LLM setting after @@ -200,6 +203,7 @@ needed for real chat/workflow execution: Do not assign `bootstrap_openai_api_key = null` in local tfvars when using `TF_VAR_bootstrap_openai_api_key`; tfvars values override runtime environment variables. The same rule applies to `bootstrap_gemini_api_key`. +It also applies to `bootstrap_meta_api_key`. Operational note: diff --git a/infra/openstack/environments/dev/dev.tfvars.example b/infra/openstack/environments/dev/dev.tfvars.example index 1ef64cea4..c7e4483a7 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_meta_api_key unset when using TF_VAR_bootstrap_meta_api_key. +# Meta Muse remains opt-in; do not make it the default without an explicit scoped model. +# bootstrap_meta_api_key = "REPLACE_WITH_LOCAL_UNTRACKED_SECRET" +bootstrap_meta_api_key_file = "/etc/von/secrets/meta_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" @@ -105,6 +109,7 @@ bootstrap_openrouter_api_key_file = "/etc/von/secrets/openrouter_api_key" # bootstrap_default_llm_provider = "openai" # bootstrap_default_llm_model = "gpt-5.5" # Gemini alternative: provider = "gemini", model = "gemini-3.7-flash". +# Meta Muse alternative: provider = "meta", model = "muse-spark-1.3". # bootstrap_default_llm_organisation_concept_id = "#V#university_of_auckland_strong_ai_lab" # Mongo Atlas hardening (dev can remain non-strict while preserving TLS defaults). diff --git a/infra/openstack/environments/prod/prod.tfvars.example b/infra/openstack/environments/prod/prod.tfvars.example index 3f1bfa29b..215c33e38 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_meta_api_key unset when using TF_VAR_bootstrap_meta_api_key. +# Meta Muse remains opt-in; do not make it the default without an explicit scoped model. +# bootstrap_meta_api_key = "REPLACE_WITH_LOCAL_UNTRACKED_SECRET" +bootstrap_meta_api_key_file = "/etc/von/secrets/meta_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" @@ -109,6 +113,7 @@ bootstrap_openrouter_api_key_file = "/etc/von/secrets/openrouter_api_key" # bootstrap_default_llm_provider = "openai" # bootstrap_default_llm_model = "gpt-5.5" # Gemini alternative: provider = "gemini", model = "gemini-3.7-flash". +# Meta Muse alternative: provider = "meta", model = "muse-spark-1.3". # bootstrap_default_llm_organisation_concept_id = "#V#university_of_auckland_strong_ai_lab" # Mongo Atlas hardening for hosted production. diff --git a/infra/openstack/environments/staging/staging.tfvars.example b/infra/openstack/environments/staging/staging.tfvars.example index 98b287094..789eba460 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_meta_api_key unset when using TF_VAR_bootstrap_meta_api_key. +# Meta Muse remains opt-in; do not make it the default without an explicit scoped model. +# bootstrap_meta_api_key = "REPLACE_WITH_LOCAL_UNTRACKED_SECRET" +bootstrap_meta_api_key_file = "/etc/von/secrets/meta_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" @@ -101,6 +105,7 @@ bootstrap_openrouter_api_key_file = "/etc/von/secrets/openrouter_api_key" # bootstrap_default_llm_provider = "openai" # bootstrap_default_llm_model = "gpt-5.5" # Gemini alternative: provider = "gemini", model = "gemini-3.7-flash". +# Meta Muse alternative: provider = "meta", model = "muse-spark-1.3". # bootstrap_default_llm_organisation_concept_id = "#V#university_of_auckland_strong_ai_lab" # Mongo Atlas hardening for hosted staging. diff --git a/infra/openstack/main.tf b/infra/openstack/main.tf index c34418619..9f39f22fa 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 + meta_api_key = var.bootstrap_meta_api_key + meta_api_key_file = var.bootstrap_meta_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 diff --git a/infra/openstack/templates/cloud-init/von_bootstrap.yaml.tftpl b/infra/openstack/templates/cloud-init/von_bootstrap.yaml.tftpl index 7bdcdf870..1d9dc50fd 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} + META_API_KEY_FILE=${meta_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)} @@ -99,6 +100,14 @@ write_files: ${gemini_api_key} %{ endif ~} +%{ if meta_api_key != null ~} + - path: ${meta_api_key_file} + owner: root:root + permissions: "0600" + content: | + ${meta_api_key} +%{ endif ~} + %{ if openrouter_api_key != null ~} - path: ${openrouter_api_key_file} owner: root:root @@ -216,7 +225,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}' '${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"] + - [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}' '${meta_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 c36d0cb48..b91fb8ce9 100644 --- a/infra/openstack/variables.tf +++ b/infra/openstack/variables.tf @@ -562,6 +562,29 @@ variable "bootstrap_gemini_api_key_file" { } } +variable "bootstrap_meta_api_key" { + type = string + description = "Optional inline Meta Model API key for managed bootstrap secret-file injection." + default = null + sensitive = true + + validation { + condition = var.bootstrap_meta_api_key == null || trimspace(var.bootstrap_meta_api_key) != "" + error_message = "bootstrap_meta_api_key must be null or non-empty." + } +} + +variable "bootstrap_meta_api_key_file" { + type = string + description = "Path used by runtime META_API_KEY_FILE." + default = "/etc/von/secrets/meta_api_key" + + validation { + condition = can(regex("^/", var.bootstrap_meta_api_key_file)) + error_message = "bootstrap_meta_api_key_file must be an absolute Linux path." + } +} + variable "bootstrap_openrouter_api_key" { type = string description = "Optional inline OpenRouter API key for managed bootstrap secret-file injection." @@ -591,8 +614,8 @@ variable "bootstrap_default_llm_provider" { default = null validation { - 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." + condition = var.bootstrap_default_llm_provider == null || contains(["openai", "openrouter", "gemini", "meta", "ollama"], lower(trimspace(var.bootstrap_default_llm_provider))) + error_message = "bootstrap_default_llm_provider must be null, openai, openrouter, gemini, meta, or ollama." } } diff --git a/scripts/bootstrap_llm_setting.py b/scripts/bootstrap_llm_setting.py index 9c3752015..5742fd013 100644 --- a/scripts/bootstrap_llm_setting.py +++ b/scripts/bootstrap_llm_setting.py @@ -13,7 +13,7 @@ from pathlib import Path -SUPPORTED_PROVIDERS = {"openai", "openrouter", "gemini", "ollama"} +SUPPORTED_PROVIDERS = {"openai", "openrouter", "gemini", "meta", "ollama"} def _normalise_concept_id(raw: str | None) -> str | None: diff --git a/src/backend/integrations/internal_mcp/catalogue.py b/src/backend/integrations/internal_mcp/catalogue.py index b8d1a50ae..04fba225b 100644 --- a/src/backend/integrations/internal_mcp/catalogue.py +++ b/src/backend/integrations/internal_mcp/catalogue.py @@ -34321,6 +34321,7 @@ def _sanitise_for_introspection(value: Any, *, depth: int = 0) -> Any: "ANTHROPIC_API_KEY", "GEMINI_API_KEY", "GEMINI_API_BACKUP_KEY", + "META_API_KEY", "GOOGLE_API_KEY", "GOOGLE_API_BACKUP_KEY", "MISTRAL_API_KEY", diff --git a/src/backend/integrations/internal_mcp/orchestrator.py b/src/backend/integrations/internal_mcp/orchestrator.py index 0d3929f53..6c9259e55 100644 --- a/src/backend/integrations/internal_mcp/orchestrator.py +++ b/src/backend/integrations/internal_mcp/orchestrator.py @@ -363,6 +363,8 @@ def _provider_from_llm_client(llm_client: Any) -> str | None: return "openai" if "gemini" in class_name: return "gemini" + if "metamuse" in class_name or "meta_muse" in class_name: + return "meta" if "anthropic" in class_name or "claude" in class_name: return "anthropic" return None @@ -385,7 +387,14 @@ def _model_identifier_looks_local_ollama(model: Any) -> bool: if cleaned.startswith("ollama:"): return True if cleaned.startswith( - ("openai:", "openrouter:", "anthropic:", "gemini:", "azure_openai:") + ( + "openai:", + "openrouter:", + "anthropic:", + "gemini:", + "meta:", + "azure_openai:", + ) ): return False if cleaned.startswith(("gpt-", "o1-", "claude", "gemini")): @@ -13654,7 +13663,13 @@ def _normalise_llm_model_name(model: Optional[str]) -> Optional[str]: candidate_tail = candidate lowered_candidate = candidate.lower() - for provider_prefix in ("openai:", "openrouter:", "ollama:", "gemini:"): + for provider_prefix in ( + "openai:", + "openrouter:", + "ollama:", + "gemini:", + "meta:", + ): if lowered_candidate.startswith(provider_prefix): candidate_tail = candidate.split(":", 1)[1].strip() break @@ -13766,7 +13781,13 @@ 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", "openrouter", "ollama", "gemini"}: + if provider_prefix in { + "openai", + "openrouter", + "ollama", + "gemini", + "meta", + }: provider = provider_prefix model = model_suffix.strip() elif candidate.lower().startswith("ft:"): @@ -13792,6 +13813,7 @@ def _parse_policy_model_candidate(value: Any) -> _ModelCandidate | None: if model == "default": from src.backend.languagemodels.model_defaults import ( DEFAULT_GEMINI_MODEL, + DEFAULT_META_MUSE_MODEL, DEFAULT_OLLAMA_MODEL, DEFAULT_OPENAI_MODEL, ) @@ -13800,6 +13822,7 @@ def _parse_policy_model_candidate(value: Any) -> _ModelCandidate | None: "ollama": DEFAULT_OLLAMA_MODEL, "openai": DEFAULT_OPENAI_MODEL, "gemini": DEFAULT_GEMINI_MODEL, + "meta": DEFAULT_META_MUSE_MODEL, } resolved = _provider_defaults.get(provider) if resolved: @@ -14022,7 +14045,13 @@ 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", "openrouter", "ollama", "gemini"}: + if provider_text in { + "openai", + "openrouter", + "ollama", + "gemini", + "meta", + }: return provider_text if raw.lower().startswith("ft:"): return "openai" @@ -15057,7 +15086,7 @@ def _create_client_for_candidate( model_implied_provider and model_implied_provider != default_provider and model_implied_provider - in {"openai", "openrouter", "ollama", "gemini"} + in {"openai", "openrouter", "ollama", "gemini", "meta"} ): requested_provider_client = model_implied_provider client = get_llm_client( @@ -15091,10 +15120,11 @@ def _create_client_for_candidate( resolved_model = resolve_ollama_model_name(resolved_model) resolved_model = self._normalise_llm_model_name(resolved_model) except Exception as exc: - if requested_provider_client == "gemini": + if requested_provider_client in {"gemini", "meta"}: + failed_provider = requested_provider_client telemetry.update( { - "provider": "gemini", + "provider": failed_provider, "model": default_model, "error": "candidate_client_resolution_failed", "error_class": type(exc).__name__, @@ -15104,7 +15134,7 @@ def _create_client_for_candidate( } ) raise _ModelCandidateClientResolutionError( - provider="gemini", + provider=failed_provider, model=default_model, telemetry=telemetry, cause_class=type(exc).__name__, @@ -15152,7 +15182,15 @@ def _create_client_for_candidate( if model: telemetry["model"] = model - if provider in {None, "", "openai", "openrouter", "ollama", "gemini"}: + if provider in { + None, + "", + "openai", + "openrouter", + "ollama", + "gemini", + "meta", + }: pass else: telemetry["error"] = "unsupported_provider" @@ -15198,10 +15236,10 @@ def _create_client_for_candidate( telemetry["model_resolution_source"] = "client_default" return client, model or default_model, telemetry except Exception as exc: - if provider == "gemini": + if provider in {"gemini", "meta"}: telemetry.update( { - "provider": "gemini", + "provider": provider, "model": model, "error": "candidate_client_resolution_failed", "error_class": type(exc).__name__, @@ -15211,7 +15249,7 @@ def _create_client_for_candidate( } ) raise _ModelCandidateClientResolutionError( - provider="gemini", + provider=provider, model=model, telemetry=telemetry, cause_class=type(exc).__name__, diff --git a/src/backend/languagemodels/llm_interface.py b/src/backend/languagemodels/llm_interface.py index 1f5546bfc..33172fc1e 100644 --- a/src/backend/languagemodels/llm_interface.py +++ b/src/backend/languagemodels/llm_interface.py @@ -28,6 +28,7 @@ classify_api_key_failover_exception, get_gemini_api_key, get_gemini_backup_api_key, + get_meta_api_key, get_openai_backup_api_key, get_openrouter_api_key, ) @@ -39,12 +40,18 @@ from ..services.model_parameter_service import ( chat_completions_kwargs_from_model_parameters, gemini_kwargs_from_model_parameters, + meta_responses_kwargs_from_model_parameters, + normalise_model_parameters_for_storage, openai_responses_kwargs_from_model_parameters, ) from .model_defaults import ( DEFAULT_GEMINI_MODEL, + DEFAULT_META_MUSE_MODEL, DEFAULT_OLLAMA_MODEL, DEFAULT_OPENAI_MODEL, + META_MUSE_MAX_OUTPUT_TOKENS, + META_MUSE_TEMPERATURE, + META_MUSE_TOP_P, ) from .structured_tool_calling import ( LLMClientConfig, @@ -164,6 +171,8 @@ def infer_llm_client_provider(client: Any) -> Optional[str]: if client is None: return None class_ref = f"{type(client).__module__}.{type(client).__name__}".lower() + if "metamuse" in class_ref: + return "meta" if "ollama" in class_ref: return "ollama" if "openai" in class_ref: @@ -537,7 +546,7 @@ def _looks_like_browser_object_model_reference(value: str) -> bool: if not cleaned: return False lowered = cleaned.lower() - for prefix in ("openai:", "openrouter:", "ollama:", "gemini:"): + for prefix in ("openai:", "openrouter:", "ollama:", "gemini:", "meta:"): if lowered.startswith(prefix): cleaned = cleaned.split(":", 1)[1].strip() break @@ -570,6 +579,19 @@ def _extract_openrouter_model_id(value: str) -> Optional[str]: return cleaned or None +def _extract_meta_muse_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("meta:"): + 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 @@ -591,7 +613,7 @@ def _looks_like_ollama_model(name: str) -> bool: return False if raw.startswith("#V#"): return False - if raw.lower().startswith(("openai:", "ft:")): + if raw.lower().startswith(("openai:", "meta:", "ft:")): return False direct = _extract_openai_model_id(raw) if direct and _looks_like_openai_model(direct): @@ -640,6 +662,13 @@ def resolve_provider_from_model_concept(model: Optional[str]) -> Optional[str]: lowered = text.lower() if "openrouter" in lowered: return "openrouter" + if ( + lowered.strip() == "meta" + or "meta model api" in lowered + or "meta muse" in lowered + or "muse spark" in lowered + ): + return "meta" if "openai" in lowered: return "openai" if "ollama" in lowered: @@ -780,6 +809,40 @@ def resolve_openrouter_model_name(model: Optional[str]) -> Optional[str]: return candidates[0] if candidates else direct +def resolve_meta_muse_model_name(model: Optional[str]) -> Optional[str]: + """Resolve only the fixed Meta Muse model enabled by this integration.""" + + 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_meta_muse_model_id(raw) + if direct == DEFAULT_META_MUSE_MODEL: + return direct + if not raw.startswith("#V#"): + return None + + 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 = [] + for row in rows: + if not isinstance(row, Mapping): + continue + text = row.get("text") + if not isinstance(text, str): + continue + candidate = _extract_meta_muse_model_id(text) + if candidate == DEFAULT_META_MUSE_MODEL: + return candidate + return None + + def _resolve_effective_llm_actor_scope( user_concept_id: Optional[str] = None, org_concept_id: Optional[str] = None, @@ -849,6 +912,8 @@ def _normalise_model_execution_key( 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 + elif provider_key == "meta": + model_key = resolve_meta_muse_model_name(model_key) or model_key return provider_key, model_key @@ -881,6 +946,7 @@ def assert_model_execution_allowed( "openai": "OpenAI", "openrouter": "OpenRouter", "gemini": "Gemini", + "meta": "Meta Muse", }.get(provider_key, provider_key or "External") if not model_key: raise ModelExecutionEligibilityError( @@ -2729,6 +2795,319 @@ def list_models(self) -> List[str]: return models +META_MODEL_API_BASE_URL = "https://api.meta.ai/v1" +META_MODEL_API_CONNECTION_ID = "#V#meta_provider" + + +class MetaMuseClient(LLMInterface): + """Opt-in Muse Spark client for Meta's OpenAI-compatible Responses API.""" + + DEFAULT_MODEL = DEFAULT_META_MUSE_MODEL + SUPPORTED_MODELS = (DEFAULT_META_MUSE_MODEL,) + + def __init__( + self, + api_key: Optional[str] = None, + api_key_env_var: str = "META_API_KEY", + *, + default_model: str = DEFAULT_META_MUSE_MODEL, + model_execution_user_concept_id: Optional[str] = None, + model_execution_org_concept_id: Optional[str] = None, + model_execution_actor_scope_bound: bool = False, + ): + if api_key_env_var != "META_API_KEY": + raise ValueError("Meta Muse uses only the META_API_KEY secret source.") + resolved_default = resolve_meta_muse_model_name(default_model) + if resolved_default != DEFAULT_META_MUSE_MODEL: + raise ValueError( + f"Meta Muse supports only model '{DEFAULT_META_MUSE_MODEL}'." + ) + self.api_key = api_key or get_meta_api_key() or "" + if not self.api_key: + raise ValueError( + "Meta Model API key not provided or found in META_API_KEY " + "or META_API_KEY_FILE." + ) + self.default_model = resolved_default + self.base_url = META_MODEL_API_BASE_URL + 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) + self.last_response_metadata: Dict[str, Any] = {} + logger.info("MetaMuseClient initialised.") + + def _target_model(self, model: Optional[str]) -> str: + candidate = resolve_meta_muse_model_name(model or self.default_model) + if candidate != DEFAULT_META_MUSE_MODEL: + raise ValueError( + f"Meta Muse supports only model '{DEFAULT_META_MUSE_MODEL}'." + ) + return candidate + + def _sanitise_error(self, exc: BaseException) -> str: + safe = sanitise_transport_telemetry_text(str(exc)) + if self.api_key: + safe = safe.replace(self.api_key, "[redacted]") + return safe + + @staticmethod + def _event_value(event: Any, key: str, default: Any = None) -> Any: + if isinstance(event, Mapping): + return event.get(key, default) + return getattr(event, key, default) + + def _consume_response_stream(self, stream: Any) -> tuple[Any, str]: + """Return the completed response and observed deltas from an SDK stream.""" + + completed_response: Any = None + text_parts: list[str] = [] + try: + for event in stream: + event_type = str(self._event_value(event, "type") or "").strip() + if event_type == "response.output_text.delta": + delta = self._event_value(event, "delta") + if isinstance(delta, str): + text_parts.append(delta) + continue + if event_type == "response.completed": + completed_response = self._event_value(event, "response") + continue + if event_type in { + "error", + "response.error", + "response.failed", + "response.incomplete", + }: + provider_error = self._event_value(event, "error") + failed_response = self._event_value(event, "response") + if provider_error is None and failed_response is not None: + provider_error = self._event_value( + failed_response, "error", event_type + ) + raise RuntimeError( + f"Meta Responses stream terminated with {event_type}: " + f"{provider_error or 'provider did not return a completed response'}" + ) + finally: + close = getattr(stream, "close", None) + if callable(close): + close() + if completed_response is None: + raise RuntimeError( + "Meta Responses stream ended without a response.completed event." + ) + return completed_response, "".join(text_parts) + + def _get_structured_client_config(self, model: Optional[str]) -> LLMClientConfig: + target_model = self._target_model(model) + return LLMClientConfig( + model=target_model, + provider="meta", + api_key=self.api_key, + base_url=self.base_url, + connection_id=META_MODEL_API_CONNECTION_ID, + deployment_id=target_model, + requested_api_surface="responses", + temperature=META_MUSE_TEMPERATURE, + max_tokens=META_MUSE_MAX_OUTPUT_TOKENS, + ) + + 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: + effective_params = normalise_model_parameters_for_storage( + llm_params, + provider="meta", + model=self._target_model(model), + api_surface="responses", + include_registry=False, + ) + reasoning_effort = effective_params.get("reasoning_effort") + effective_params = ( + {"reasoning_effort": reasoning_effort} + if isinstance(reasoning_effort, str) and reasoning_effort + else {"reasoning_effort": "medium"} + ) + options = { + key: value + for key, value in provider_options.items() + if key + in { + "continuation", + "tool_results", + "tool_choice", + "parallel_tool_calls", + } + } + options["top_p"] = META_MUSE_TOP_P + response = super().generate_with_tools( + prompt=prompt, + available_tools=available_tools, + context=context, + model=model, + system_message=system_message, + llm_params=effective_params, + **options, + ) + response.transport_metadata["stream"] = True + return response + + 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 = self._target_model(model) + self._assert_model_execution_allowed(provider="meta", model=target_model) + request_started_monotonic = time.monotonic() + llm_params_for_model, request_advisory_seconds = ( + _split_request_timeout_from_llm_params(llm_params) + ) + effective_params = normalise_model_parameters_for_storage( + llm_params_for_model, + provider="meta", + model=target_model, + api_surface="responses", + include_registry=False, + ) + effective_params.setdefault("reasoning_effort", "medium") + request_kwargs = meta_responses_kwargs_from_model_parameters( + effective_params, + model=target_model, + ) + projected_reasoning = request_kwargs.get("reasoning") + request_kwargs = ( + {"reasoning": dict(projected_reasoning)} + if isinstance(projected_reasoning, Mapping) + else {} + ) + for reserved_key in ( + "model", + "input", + "instructions", + "tools", + "stream", + ): + request_kwargs.pop(reserved_key, None) + request_kwargs.setdefault("reasoning", {"effort": "medium"}) + request_kwargs["temperature"] = META_MUSE_TEMPERATURE + request_kwargs["top_p"] = META_MUSE_TOP_P + request_kwargs["max_output_tokens"] = META_MUSE_MAX_OUTPUT_TOKENS + request_kwargs["store"] = False + request_kwargs["include"] = ["reasoning.encrypted_content"] + input_items = to_openai_messages(build_conversation(prompt, context)) + + try: + stream = self.client.responses.create( # type: ignore[attr-defined] + model=target_model, + input=input_items, # type: ignore[arg-type] + stream=True, + extra_headers={"Accept": "text/event-stream"}, + **request_kwargs, + ) + response, streamed_text = self._consume_response_stream(stream) + actual_model = _read_response_field(response, "model") or target_model + content = _extract_openai_responses_text(response) or streamed_text + if not isinstance(content, str) or not content.strip(): + raise RuntimeError("Meta Responses payload contained no text.") + usage = OpenAIClient._openai_usage_mapping( + _read_response_field(response, "usage") + ) + duration_ms = int( + max(0.0, time.monotonic() - request_started_monotonic) * 1000 + ) + transport_metadata: Dict[str, Any] = { + "requested_provider": "meta", + "effective_provider": "meta", + "requested_model": target_model, + "effective_model": actual_model, + "effective_api_surface": "responses", + "connection_id": META_MODEL_API_CONNECTION_ID, + "deployment_id": target_model, + "generation_id": sanitise_transport_telemetry_value( + _read_response_field(response, "id") + ), + "provider_status": sanitise_transport_telemetry_value( + _read_response_field(response, "status") + ), + "stream": True, + "store": False, + "duration_ms": duration_ms, + "effective_provider_parameters": sanitise_transport_telemetry_value( + request_kwargs + ), + } + self.last_response_metadata = { + "provider": "meta", + "api_surface": "responses", + "requested_model": target_model, + "effective_model": actual_model, + "usage": usage, + "transport_metadata": transport_metadata, + "raw_response": { + "id": _read_response_field(response, "id"), + "model": actual_model, + "status": _read_response_field(response, "status"), + }, + } + _observe_request_advisory( + provider="meta", + advisory_seconds=request_advisory_seconds, + started_monotonic=request_started_monotonic, + ) + return content.strip() + except ModelExecutionEligibilityError: + raise + except Exception as exc: + safe_error = self._sanitise_error(exc) + logger.error( + "Meta Responses request failed (error_type=%s): %s", + type(exc).__name__, + safe_error, + ) + raise RuntimeError(f"Meta Responses request failed: {safe_error}") from exc + + def get_embedding(self, text: str, model: Optional[str] = None) -> List[float]: + raise NotImplementedError( + "Meta Muse embeddings are not enabled in this release." + ) + + def list_models(self) -> List[str]: + try: + response = self.client.models.list() + raw_models = _read_response_field(response, "data") or [] + available = { + model_id + for item in raw_models + if isinstance( + (model_id := _read_response_field(item, "id")), str + ) + } + return [model for model in self.SUPPORTED_MODELS if model in available] + except Exception as exc: + safe_error = self._sanitise_error(exc) + logger.error( + "Meta model catalogue request failed (error_type=%s): %s", + type(exc).__name__, + safe_error, + ) + raise RuntimeError( + f"Meta model catalogue request failed: {safe_error}" + ) from exc + + class GeminiClient(LLMInterface): """Client for interacting with the Google Gemini API.""" @@ -3449,6 +3828,29 @@ def get_llm_client( "No cross-provider fallback was attempted." ) from e + elif provider == "meta": + try: + trusted_user_concept_id, trusted_org_concept_id = ( + _resolve_effective_llm_actor_scope( + user_concept_id, + org_concept_id, + ) + ) + meta_kwargs = dict(kwargs) + if model: + meta_kwargs.setdefault("default_model", model) + meta_kwargs["model_execution_user_concept_id"] = trusted_user_concept_id + meta_kwargs["model_execution_org_concept_id"] = trusted_org_concept_id + meta_kwargs["model_execution_actor_scope_bound"] = True + return MetaMuseClient(**meta_kwargs) + except Exception as e: + safe_error = sanitise_transport_telemetry_text(str(e)) + logger.error("Failed to initialise Meta Muse client: %s", safe_error) + raise RuntimeError( + "Meta Muse 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 = ( diff --git a/src/backend/languagemodels/model_defaults.py b/src/backend/languagemodels/model_defaults.py index 566cd1f14..252077cea 100644 --- a/src/backend/languagemodels/model_defaults.py +++ b/src/backend/languagemodels/model_defaults.py @@ -1,8 +1,9 @@ """Centralised default model names for all LLM providers. All code that needs a fallback model name should import from here rather than -hardcoding a model string. Each default is overridable via an environment -variable so operators can change the fallback without code changes. +hardcoding a model string. Provider defaults are environment-overridable where +the integration supports a model portfolio; single-model provider contracts +remain fixed to their verified model ID. """ import os @@ -21,3 +22,14 @@ # Gemini # --------------------------------------------------------------------------- DEFAULT_GEMINI_MODEL: str = os.getenv("VON_DEFAULT_GEMINI_MODEL", "gemini-3.7-flash") + +# --------------------------------------------------------------------------- +# Meta Model API +# --------------------------------------------------------------------------- +# This bounded provider integration intentionally exposes only Muse Spark 1.3. +# Do not add a second configuration channel for the model ID: the represented +# model registry remains the durable catalogue and selection authority. +DEFAULT_META_MUSE_MODEL: str = "muse-spark-1.3" +META_MUSE_TEMPERATURE: float = 1.0 +META_MUSE_TOP_P: float = 1.0 +META_MUSE_MAX_OUTPUT_TOKENS: int = 32000 diff --git a/src/backend/languagemodels/structured_tool_calling/factory.py b/src/backend/languagemodels/structured_tool_calling/factory.py index 5381aa8ce..12db741e6 100644 --- a/src/backend/languagemodels/structured_tool_calling/factory.py +++ b/src/backend/languagemodels/structured_tool_calling/factory.py @@ -30,6 +30,8 @@ def get_llm_client(config: LLMClientConfig) -> LLMClient: return OpenAIClient(config) if provider == "openrouter": return OpenAIClient(config) + if provider == "meta": + return OpenAIClient(config) if provider == "gemini": return GeminiClient(config) if provider == "ollama": 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 c7ae4bfa4..a28156a0e 100644 --- a/src/backend/languagemodels/structured_tool_calling/providers/openai_client.py +++ b/src/backend/languagemodels/structured_tool_calling/providers/openai_client.py @@ -44,13 +44,18 @@ ToolResult, UnsupportedStructuredToolTransportError, ) +from ...model_defaults import ( + META_MUSE_MAX_OUTPUT_TOKENS, + META_MUSE_TEMPERATURE, + META_MUSE_TOP_P, +) from ....integrations.internal_mcp.tool_call_contracts import validation_diagnostic from ....services.llm_api_key_resolution import ( classify_api_key_failover_exception, ) from ....services.model_parameter_service import ( chat_completions_kwargs_from_model_parameters, - openai_responses_kwargs_from_model_parameters, + responses_kwargs_from_model_parameters, ) @@ -107,6 +112,14 @@ def _merge_provider_parameter_kwargs( target[key] = value +def _provider_label(provider: str) -> str: + return { + "openai": "OpenAI", + "openrouter": "OpenRouter", + "meta": "Meta Model API", + }.get(str(provider or "").strip().lower(), "OpenAI-compatible provider") + + class OpenAIClient(LLMClient): """OpenAI client whose wire surface is selected from represented profiles.""" @@ -204,7 +217,7 @@ 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" + provider_label = _provider_label(decision.provider) if decision.status != "compatible": raise UnsupportedStructuredToolTransportError( "No represented API profile supports structured tools for " @@ -310,6 +323,10 @@ async def _invoke_with_credential_failover( raise except Exception as exc: safe_error = self._sanitise_provider_error(exc) + if decision.provider == "meta": + for secret in (self.config.api_key, self.config.backup_api_key): + if secret: + safe_error = safe_error.replace(secret, "[redacted]") self.logger.error( "%s structured-tool API error: %s", provider_label, safe_error ) @@ -600,6 +617,13 @@ async def _generate_chat_completions( profile_concept_id=decision.profile_concept_id, ), ) + if decision.provider == "meta": + projected_reasoning = effective_parameters.get("reasoning") + effective_parameters = ( + {"reasoning": dict(projected_reasoning)} + if isinstance(projected_reasoning, Mapping) + else {} + ) _merge_provider_parameter_kwargs(request_kwargs, effective_parameters) if ( tools @@ -718,10 +742,15 @@ async def _generate_responses( requested_parameters["reasoning_effort"] = direct_reasoning_effort _merge_provider_parameter_kwargs( effective_parameters, - openai_responses_kwargs_from_model_parameters( + responses_kwargs_from_model_parameters( {"reasoning_effort": direct_reasoning_effort}, + provider=decision.provider, model=request_model, profile_concept_id=decision.profile_concept_id, + include_registry=( + decision.provider != "meta" + or decision.profile_concept_id is not None + ), ), ) input_items = self._build_responses_input( @@ -731,14 +760,19 @@ async def _generate_responses( tool_results=tool_results, ) if self.config.max_tokens is not None: - request_kwargs["max_output_tokens"] = self.config.max_tokens + request_kwargs.setdefault("max_output_tokens", self.config.max_tokens) if isinstance(llm_params, Mapping) and llm_params: _merge_provider_parameter_kwargs( effective_parameters, - openai_responses_kwargs_from_model_parameters( + responses_kwargs_from_model_parameters( llm_params, + provider=decision.provider, model=request_model, profile_concept_id=decision.profile_concept_id, + include_registry=( + decision.provider != "meta" + or decision.profile_concept_id is not None + ), ), ) _merge_provider_parameter_kwargs(request_kwargs, effective_parameters) @@ -747,21 +781,34 @@ async def _generate_responses( and "temperature" in request_kwargs ): requested_parameters["temperature"] = request_kwargs["temperature"] - requested_temperature = requested_parameters.get( - "temperature", - request_kwargs.get("temperature", self.config.temperature), + requested_temperature = ( + META_MUSE_TEMPERATURE + if decision.provider == "meta" + else requested_parameters.get( + "temperature", + request_kwargs.get("temperature", self.config.temperature), + ) ) request_kwargs.pop("temperature", None) effective_parameters.pop("temperature", None) - safe_temperature = resolve_safe_temperature_for_model( - request_model, - requested_temperature, - api_surface=API_SURFACE_RESPONSES, - profile_concept_id=decision.profile_concept_id, + safe_temperature = ( + META_MUSE_TEMPERATURE + if decision.provider == "meta" + else resolve_safe_temperature_for_model( + request_model, + requested_temperature, + api_surface=API_SURFACE_RESPONSES, + profile_concept_id=decision.profile_concept_id, + ) ) if safe_temperature is not None: request_kwargs["temperature"] = safe_temperature effective_parameters["temperature"] = safe_temperature + if decision.provider == "meta": + request_kwargs["max_output_tokens"] = META_MUSE_MAX_OUTPUT_TOKENS + request_kwargs.pop("top_p", None) + request_kwargs["top_p"] = META_MUSE_TOP_P + effective_parameters["top_p"] = META_MUSE_TOP_P request_kwargs["store"] = decision.store if decision.continuation_mode == "stateless" and not decision.store: requested_include = request_kwargs.get("include") @@ -784,6 +831,13 @@ async def _generate_responses( ): request_kwargs["previous_response_id"] = continuation.response_id + if decision.provider == "meta": + # Meta Muse is exposed through the streaming Responses contract. + # The final completed response remains the canonical source for + # tool items, usage, and provider-observed identity. + request_kwargs["stream"] = True + request_kwargs["extra_headers"] = {"Accept": "text/event-stream"} + response = await request_client.responses.create( model=request_model, input=input_items, # type: ignore[arg-type] @@ -791,6 +845,11 @@ async def _generate_responses( tools=tools, # type: ignore[arg-type] **request_kwargs, ) + if decision.provider == "meta": + response = await self._consume_meta_responses_stream( + response, + decision=decision, + ) parsed = self._parse_responses_response( response, available_tools, @@ -820,8 +879,65 @@ async def _generate_responses( requested=requested_parameters, effective=effective_parameters, ) + if decision.provider == "meta": + parsed.transport_metadata["stream"] = True return parsed + @staticmethod + async def _consume_meta_responses_stream( + stream: Any, + *, + decision: StructuredToolTransportDecision, + ) -> Any: + """Return the final response.completed payload from Meta's SDK stream.""" + + if not hasattr(stream, "__aiter__"): + raise StructuredToolProtocolError( + "Meta Responses did not return an asynchronous event stream.", + decision={ + **decision.to_telemetry(), + "failure_kind": "meta_stream_missing", + }, + ) + completed_response: Any = None + try: + async for event in stream: + event_type = str(_value(event, "type") or "").strip() + if event_type == "response.completed": + completed_response = _value(event, "response") + continue + if event_type in { + "error", + "response.error", + "response.failed", + "response.incomplete", + }: + raise StructuredToolTransportError( + f"Meta Responses stream terminated with {event_type}.", + decision={ + **decision.to_telemetry(), + "failure_kind": "meta_stream_provider_failure", + "provider_event_type": event_type, + }, + ) + finally: + close = getattr(stream, "close", None) + if not callable(close): + close = getattr(stream, "aclose", None) + if callable(close): + result = close() + if inspect.isawaitable(result): + await result + if completed_response is None: + raise StructuredToolProtocolError( + "Meta Responses stream ended without response.completed.", + decision={ + **decision.to_telemetry(), + "failure_kind": "meta_stream_incomplete", + }, + ) + return completed_response + def generate_with_tools_sync( self, prompt: str, diff --git a/src/backend/languagemodels/structured_tool_calling/transport.py b/src/backend/languagemodels/structured_tool_calling/transport.py index 9a87eead6..8142f64f9 100644 --- a/src/backend/languagemodels/structured_tool_calling/transport.py +++ b/src/backend/languagemodels/structured_tool_calling/transport.py @@ -340,7 +340,7 @@ def resolve_structured_tool_transport( requested_surface = _normalise(requested_api_surface) or "auto" projection = dict(parameter_projection or {}) - if provider_key not in {"openai", "openrouter"}: + if provider_key not in {"openai", "openrouter", "meta"}: return StructuredToolTransportDecision( provider=provider_key, model=model_name, @@ -522,6 +522,94 @@ def resolve_structured_tool_transport( if item[1] == API_SURFACE_CHAT_COMPLETIONS ] + if provider_key == "meta": + stateful_profiles = [ + item + for item in applicable + if item[3] != "stateless" or item[5] + ] + if stateful_profiles: + profile, surface, _capability, _mode, _policy, _store, _specificity = ( + stateful_profiles[0] + ) + return StructuredToolTransportDecision( + **common, + effective_api_surface=surface, + status="unsupported", + reason="meta_stateless_responses_only", + capability_class="provider_surface_not_enabled", + capability_source=str( + provenance.get("source") or "model_registry" + ), + profile_concept_id=( + str(profile.get("profile_concept_id")) + if profile.get("profile_concept_id") + else None + ), + advertised_alternatives=(), + ) + non_responses_required_surfaces = required_surfaces - { + API_SURFACE_RESPONSES + } + if non_responses_required_surfaces: + return StructuredToolTransportDecision( + **common, + effective_api_surface=sorted(non_responses_required_surfaces)[0], + status="unsupported", + reason="meta_responses_only", + capability_class="provider_surface_not_enabled", + capability_source=str( + provenance.get("source") or "model_registry" + ), + advertised_alternatives=(), + ) + non_responses_advertised_surfaces = { + item[1] + for item in advertised + if item[1] != API_SURFACE_RESPONSES + } + if non_responses_advertised_surfaces: + return StructuredToolTransportDecision( + **common, + effective_api_surface=sorted(non_responses_advertised_surfaces)[0], + status="unsupported", + reason="meta_responses_only", + capability_class="provider_surface_not_enabled", + capability_source=str( + provenance.get("source") or "model_registry" + ), + advertised_alternatives=(), + ) + if requested_surface not in {"auto", API_SURFACE_RESPONSES}: + return StructuredToolTransportDecision( + **common, + effective_api_surface=requested_surface, + status="unsupported", + reason="meta_responses_only", + capability_class="provider_surface_not_enabled", + capability_source=str( + provenance.get("source") or "model_registry" + ), + advertised_alternatives=(), + ) + # Meta's bounded provider contract exposes Responses only. A represented + # Chat profile cannot create a fallback path that the client does not + # support. + applicable = [ + item for item in applicable if item[1] == API_SURFACE_RESPONSES + ] + advertised = [ + item for item in advertised if item[1] == API_SURFACE_RESPONSES + ] + explicitly_unsupported = [ + surface + for surface in explicitly_unsupported + if surface == API_SURFACE_RESPONSES + ] + required_surfaces = { + item[1] for item in advertised if item[2] == "required" + } + # 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. @@ -651,6 +739,30 @@ def resolve_structured_tool_transport( ), ) + if provider_key == "meta": + if API_SURFACE_RESPONSES in explicitly_unsupported: + return StructuredToolTransportDecision( + **common, + effective_api_surface=API_SURFACE_RESPONSES, + status="unsupported", + reason="represented_profiles_explicitly_unsupported", + capability_class="explicitly_unsupported", + capability_source=str( + provenance.get("source") or "model_registry" + ), + advertised_alternatives=(), + ) + return StructuredToolTransportDecision( + **common, + effective_api_surface=API_SURFACE_RESPONSES, + status="compatible", + reason="meta_responses_provider_contract", + capability_class="provider_contract_responses", + capability_source="provider_interface_contract", + continuation_mode="stateless", + store=False, + ) + return StructuredToolTransportDecision( **common, effective_api_surface=API_SURFACE_CHAT_COMPLETIONS, @@ -724,6 +836,18 @@ def resolve_structured_tool_transport( advertised_alternatives=(), ) + if provider_key == "meta": + return StructuredToolTransportDecision( + **common, + effective_api_surface=API_SURFACE_RESPONSES, + status="compatible", + reason="meta_responses_provider_contract", + capability_class="provider_contract_responses", + capability_source="provider_interface_contract", + continuation_mode="stateless", + store=False, + ) + return StructuredToolTransportDecision( **common, effective_api_surface=API_SURFACE_CHAT_COMPLETIONS, diff --git a/src/backend/server/routes/settings_routes.py b/src/backend/server/routes/settings_routes.py index f86070ef9..792562d99 100644 --- a/src/backend/server/routes/settings_routes.py +++ b/src/backend/server/routes/settings_routes.py @@ -70,8 +70,10 @@ ) from ...services.window_session_context_service import get_effective_context from ...security.access_control import ( + LEGACY_IDENTITY_HEADER_ACTOR_SOURCE, get_effective_organisation_concept_id, get_effective_user_concept_id, + get_effective_user_concept_id_with_source, ) from ...services.buttonify_service import BUTTONIFY_PROMPT_IDS from ...services.workflow_capability_service import ( @@ -99,6 +101,7 @@ from ...services.concept_service import ConceptNotFoundError from ...languagemodels.llm_interface import ( GeminiClient, + MetaMuseClient, ModelExecutionEligibilityError, OpenAIClient, OpenRouterClient, @@ -256,6 +259,8 @@ def _validate_unified_concept_schema(concept, index): "settings", __name__ ) # REMOVED url_prefix, as it's set during registration +META_MUSE_SUPPORTED_MODELS = ("muse-spark-1.3",) + _SHARED_RUNTIME_MODEL_SETTING_KEYS = frozenset( {"server_default_llm", "rag_embedder", "rag_llm"} ) @@ -807,7 +812,13 @@ 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", "openrouter", "gemini", "ollama"): + if effective_provider_override in ( + "openai", + "openrouter", + "gemini", + "meta", + "ollama", + ): provider = effective_provider_override effective_model_override = str( request.args.get("effective_model") or "" @@ -892,6 +903,31 @@ def get_llm_info(): type(e).__name__, ) + elif provider == "meta": + api_key = _resolve_settings_api_key("META_API_KEY") + if not api_key: + status = "missing_key" + error_message = "Meta API key not set" + else: + try: + available_models = MetaMuseClient( + api_key=api_key, + **({"default_model": model} if model else {}), + ).list_models() + if model and model not in available_models: + status = "error" + error_message = "The configured Meta model is not available." + else: + status = "ready" + ping_ok = True + except Exception as e: + status = "error" + error_message = "Meta status check failed." + current_app.logger.warning( + "Meta LLM status check failed (error_type=%s)", + type(e).__name__, + ) + elif provider == "ollama": host = get_active_ollama_host() details["host"] = host @@ -1061,6 +1097,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", + "meta_api_key_env_var": "META_API_KEY", "openrouter_api_key_env_var": "OPENROUTER_API_KEY", "buttonify_prompt_ids": list(BUTTONIFY_PROMPT_IDS), "buttonify_prompt_active": ( @@ -1926,15 +1963,20 @@ def _resolve_settings_api_key( value = load_secret_from_env_or_file(name, f"{name}_FILE") if value: return value - dotenv_values = read_repo_dotenv_values(names) + dotenv_keys = tuple(key for name in names for key in (name, f"{name}_FILE")) + dotenv_values = read_repo_dotenv_values(dotenv_keys) for name in names: value = dotenv_values.get(name) if value: return value + value = read_secret_file(dotenv_values.get(f"{name}_FILE")) + if value: + return value return None _GEMINI_SETTINGS_KEY_ENV_VARS = ("GEMINI_API_KEY", "GOOGLE_API_KEY") +_META_SETTINGS_KEY_ENV_VAR = "META_API_KEY" def _normalise_settings_key_check_env_var(value: object) -> str | None: @@ -1943,6 +1985,8 @@ def _normalise_settings_key_check_env_var(value: object) -> str | None: name = str(value or "").strip() if name == "OPENROUTER_API_KEY": return name + if name == _META_SETTINGS_KEY_ENV_VAR: + return name if name in _GEMINI_SETTINGS_KEY_ENV_VARS: return name try: @@ -1988,6 +2032,13 @@ def _normalise_gemini_settings_key_env_var(value: object) -> str | None: return name if name in _GEMINI_SETTINGS_KEY_ENV_VARS else None +def _normalise_meta_settings_key_env_var(value: object) -> str | None: + """Accept only Meta's canonical provider-specific key source.""" + + name = str(value or "").strip() + return name if name == _META_SETTINGS_KEY_ENV_VAR else None + + @settings_bp.route("/models/ollama", methods=["GET"]) def get_ollama_models(): """API endpoint to get available Ollama models regardless of current selection.""" @@ -2136,6 +2187,36 @@ def get_openrouter_models(): ) +@settings_bp.route("/models/meta", methods=["GET"]) +def get_meta_models(): + """Return the available subset of Von's fixed Meta Muse catalogue.""" + + api_key = _resolve_settings_api_key(_META_SETTINGS_KEY_ENV_VAR) + if not api_key: + return _jsonify_no_store( + {"error": "Meta API key was not found in META_API_KEY."}, + 400, + ) + try: + models = [ + model + for model in MetaMuseClient(api_key=api_key).list_models() + if model in META_MUSE_SUPPORTED_MODELS + ] + if not models: + raise RuntimeError("fixed Meta Muse catalogue is unavailable") + return _jsonify_no_store(models, 200) + except Exception as exc: + current_app.logger.warning( + "Could not load the supported Meta Muse catalogue (error_type=%s)", + type(exc).__name__, + ) + return _jsonify_no_store( + {"error": "Could not load the supported Meta Muse catalogue."}, + 400, + ) + + @settings_bp.route("/model_parameters/capabilities", methods=["GET"]) def get_model_parameter_capabilities(): provider = str(request.args.get("provider") or "").strip().lower() @@ -2596,6 +2677,62 @@ def verify_gemini_api_key(): ) +@settings_bp.route("/meta/verify", methods=["POST"]) +def verify_meta_api_key(): + """Verify the Meta key with catalogue discovery, without model generation.""" + + payload = request.get_json(silent=True) or {} + api_key_env_var = _normalise_meta_settings_key_env_var( + payload.get("api_key_env_var") or _META_SETTINGS_KEY_ENV_VAR + ) + if not api_key_env_var: + return _jsonify_no_store( + { + "success": False, + "error": "Meta key source must be META_API_KEY.", + }, + 400, + ) + api_key = _resolve_settings_api_key(api_key_env_var) + if not api_key: + return _jsonify_no_store( + { + "success": False, + "error": "Meta API key was not found in META_API_KEY.", + }, + 400, + ) + + try: + models = [ + model + for model in MetaMuseClient(api_key=api_key).list_models() + if model in META_MUSE_SUPPORTED_MODELS + ] + if not models: + raise RuntimeError("fixed Meta Muse catalogue is unavailable") + return _jsonify_no_store( + { + "success": True, + "models": models, + "verification_kind": "authenticated_model_catalogue", + }, + 200, + ) + except Exception as exc: + current_app.logger.warning( + "Meta API key configuration check failed (error_type=%s)", + type(exc).__name__, + ) + return _jsonify_no_store( + { + "success": False, + "error": "Could not load the supported Meta Muse catalogue.", + }, + 400, + ) + + @settings_bp.route("/openrouter/verify", methods=["POST"]) def verify_openrouter_api_key(): """Verify the fixed OpenRouter key source by listing its current catalogue.""" @@ -3977,6 +4114,67 @@ def _classify_gemini_probe_exception(exc: Exception) -> tuple[str, str]: return "unexpected_error", "Unexpected Gemini model probe failure." +def _classify_meta_probe_exception(exc: Exception) -> tuple[str, str]: + """Map Meta failures to bounded, secret-free Settings diagnostics.""" + + raw_tokens = [ + getattr(exc, "failure_kind", None), + getattr(exc, "provider_error_code", None), + getattr(exc, "code", None), + ] + body = getattr(exc, "body", None) + if isinstance(body, Mapping): + raw_tokens.extend(body.get(key) for key in ("code", "type", "status")) + nested_error = body.get("error") + if isinstance(nested_error, Mapping): + raw_tokens.extend( + nested_error.get(key) for key in ("code", "type", "status") + ) + tokens = { + str(value).strip().lower().replace("-", "_").replace(" ", "_") + for value in raw_tokens + if value is not None and str(value).strip() + } + lowered = str(exc).strip().lower() + status_code = getattr(exc, "status_code", None) + if status_code is None: + status_code = getattr(getattr(exc, "response", None), "status_code", None) + try: + status_code = int(status_code) if status_code is not None else None + except (TypeError, ValueError): + status_code = None + + if ( + "billing_not_configured" in tokens + or "billing_not_configured" in lowered + or "billing not configured" in lowered + or status_code == 402 + ): + return ( + "billing_not_configured", + "Meta API billing is not configured for this key.", + ) + if tokens.intersection({"authentication_error", "authentication_failed"}) or ( + status_code == 401 + ): + return "authentication_error", "Meta API authentication failed." + if "permission_denied" in tokens or status_code == 403: + return "permission_denied", "Meta API access was denied for this model." + if "model_not_found" in tokens or status_code == 404: + return "model_not_found", "The selected Meta Muse model was not found." + if "quota_exhausted" in tokens: + return "quota_exhausted", "Meta API quota is exhausted." + if "rate_limited" in tokens or status_code == 429: + return "rate_limited", "Meta API rate limit exceeded." + if "bad_request" in tokens or status_code == 400: + return "bad_request", "Meta rejected the selected model probe." + if "connection_error" in tokens or any( + marker in lowered for marker in ("connect", "network", "dns") + ): + return "connection_error", "Could not reach the Meta API." + return "unexpected_error", "Unexpected Meta Muse model probe failure." + + def _gemini_probe_api_surface(client: object, model: str) -> str: metadata = getattr(client, "last_response_metadata", None) observed = ( @@ -4139,6 +4337,167 @@ def test_gemini_model(): ) +@settings_bp.route("/meta/test_model", methods=["POST"]) +def test_meta_model(): + """Run a paid probe only for an exactly allowed Meta Muse model.""" + + payload = request.get_json(silent=True) or {} + api_key_env_var = _normalise_meta_settings_key_env_var( + payload.get("api_key_env_var") or _META_SETTINGS_KEY_ENV_VAR + ) + requested_model = str(payload.get("model") or "").strip() + if not api_key_env_var: + return _jsonify_no_store( + { + "success": False, + "usable": False, + "failure_kind": "invalid_key_env_var", + "reason": "Meta key source must be META_API_KEY.", + }, + 400, + ) + if not requested_model: + return _jsonify_no_store( + { + "success": False, + "usable": False, + "failure_kind": "missing_model", + "reason": "No Meta Muse model is selected.", + }, + 400, + ) + if requested_model not in META_MUSE_SUPPORTED_MODELS: + return _jsonify_no_store( + { + "success": False, + "usable": False, + "model": requested_model, + "failure_kind": "model_unavailable", + "reason": "The selected Meta Muse model is not supported by Von.", + }, + 400, + ) + + actor_user_concept_id, actor_source = ( + get_effective_user_concept_id_with_source() + ) + if ( + not actor_user_concept_id + or actor_source == LEGACY_IDENTITY_HEADER_ACTOR_SOURCE + ): + return _jsonify_no_store( + { + "success": False, + "usable": False, + "model": requested_model, + "failure_kind": "authenticated_actor_context_required", + "reason": ( + "An authenticated actor context is required before testing " + "a paid Meta model." + ), + }, + 401, + ) + actor_org_concept_id = get_effective_organisation_concept_id() + try: + assert_model_execution_allowed( + provider="meta", + model=requested_model, + user_concept_id=actor_user_concept_id, + org_concept_id=actor_org_concept_id, + allow_ambient_actor_scope=False, + ) + 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, + ) + + # Credential resolution and client construction occur only after the exact + # actor-scoped provider/model eligibility check above. + api_key = _resolve_settings_api_key(api_key_env_var) + if not api_key: + return _jsonify_no_store( + { + "success": False, + "usable": False, + "model": requested_model, + "failure_kind": "missing_api_key", + "reason": "Meta API key was not found in META_API_KEY.", + }, + 400, + ) + + model_parameters = normalise_model_parameters_for_storage( + payload.get(MODEL_PARAMETERS_KEY) or payload.get("modelParameters"), + provider="meta", + model=requested_model, + api_surface="responses", + include_registry=True, + ) + parameter_capabilities = build_model_parameter_capabilities( + provider="meta", + model=requested_model, + api_surface="responses", + include_registry=True, + ) + try: + client = MetaMuseClient( + api_key=api_key, + default_model=requested_model, + model_execution_user_concept_id=actor_user_concept_id, + model_execution_org_concept_id=actor_org_concept_id, + model_execution_actor_scope_bound=True, + ) + client.generate( + "Reply exactly with OK.", + model=requested_model, + llm_params=model_parameters or None, + ) + metadata = getattr(client, "last_response_metadata", None) + observed_surface = ( + str(metadata.get("api_surface") or "").strip() + if isinstance(metadata, Mapping) + else "" + ) + 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 Meta Muse model completed a live probe successfully.", + "api_surface": observed_surface or "responses", + } + ) + except Exception as exc: + failure_kind, reason = _classify_meta_probe_exception(exc) + current_app.logger.warning( + "[meta_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("/openrouter/test_model", methods=["POST"]) def test_openrouter_model(): """Probe an already-allowed OpenRouter model with the conservative profile.""" diff --git a/src/backend/server/routes/von_routes.py b/src/backend/server/routes/von_routes.py index e382df1ce..e1b07d780 100644 --- a/src/backend/server/routes/von_routes.py +++ b/src/backend/server/routes/von_routes.py @@ -7500,7 +7500,13 @@ 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", "openrouter", "ollama", "gemini"}: + if requested_provider_name not in { + "openai", + "openrouter", + "ollama", + "gemini", + "meta", + }: requested_provider_name = None explicit_client_type = None @@ -7541,7 +7547,13 @@ def _resolve_generate_requested_model( active_provider = ( str(resolved_setting.get("provider") or "").strip().lower() ) - if active_provider in {"openai", "openrouter", "ollama", "gemini"}: + if active_provider in { + "openai", + "openrouter", + "ollama", + "gemini", + "meta", + }: explicit_client_type = active_provider model_name = get_active_model_name( user_concept_id=user_concept_id, @@ -13501,6 +13513,8 @@ def _infer_provider(model_id: str | None) -> str | None: return "openai" if lowered.startswith("gemini"): return "gemini" + if lowered.startswith("meta:") or lowered == "muse-spark-1.3": + return "meta" if lowered.startswith("ollama:"): return "ollama" if ":" in lowered and not lowered.startswith("ft:"): diff --git a/src/backend/services/adaptive_turn_service.py b/src/backend/services/adaptive_turn_service.py index 1e1d0bef3..b78e50820 100644 --- a/src/backend/services/adaptive_turn_service.py +++ b/src/backend/services/adaptive_turn_service.py @@ -6874,7 +6874,13 @@ def execute_adaptive_turn( configured_provider = next( ( provider_name - for provider_name in ("openai", "openrouter", "gemini", "ollama") + for provider_name in ( + "openai", + "openrouter", + "gemini", + "meta", + "ollama", + ) if provider_name in client_name ), "", diff --git a/src/backend/services/concept_service.py b/src/backend/services/concept_service.py index b486f345d..50cbca22a 100644 --- a/src/backend/services/concept_service.py +++ b/src/backend/services/concept_service.py @@ -2671,7 +2671,7 @@ def get_user_concept_tracking_collection_service() -> Optional[Collection]: # --- Interaction Session Management --- -_INTERACTION_LLM_PROVIDERS = {"openai", "openrouter", "ollama", "gemini"} +_INTERACTION_LLM_PROVIDERS = {"openai", "openrouter", "ollama", "gemini", "meta"} def _canonical_organisation_concept_id(value: Any) -> Optional[str]: diff --git a/src/backend/services/llm_api_key_resolution.py b/src/backend/services/llm_api_key_resolution.py index b12d69c72..ebf0f5951 100644 --- a/src/backend/services/llm_api_key_resolution.py +++ b/src/backend/services/llm_api_key_resolution.py @@ -71,6 +71,12 @@ def get_openrouter_api_key() -> str | None: return _first_nonempty_env_value(("OPENROUTER_API_KEY",)) +def get_meta_api_key() -> str | None: + """Return the Meta Model API key from its sole supported secret source.""" + + return _first_nonempty_env_value(("META_API_KEY",)) + + def _normalised_provider_token(value: Any) -> str | None: if not isinstance(value, (str, int)): return None diff --git a/src/backend/services/model_parameter_service.py b/src/backend/services/model_parameter_service.py index ab7c73c58..01f2a9c0d 100644 --- a/src/backend/services/model_parameter_service.py +++ b/src/backend/services/model_parameter_service.py @@ -16,6 +16,7 @@ MODEL_PARAMETER_CAPABILITY_SCHEMA = "model_parameter_capabilities.v1" OPENAI_REASONING_EFFORT_VALUES = ("none", "minimal", "low", "medium", "high", "xhigh") GEMINI_REASONING_EFFORT_VALUES = ("low", "medium", "high") +META_MUSE_REASONING_EFFORT_VALUES = ("minimal", "low", "medium", "high") _OPENAI_REASONING_EFFORT_API_MAPPINGS = { "responses": { @@ -39,6 +40,13 @@ }, } +_META_MUSE_REASONING_EFFORT_API_MAPPINGS = { + "responses": { + "type": "nested_object", + "path": ["reasoning", "effort"], + }, +} + _MODEL_PARAMETER_ALIASES = { "reasoningeffort": MODEL_PARAMETER_REASONING_EFFORT, "reasoning_effort": MODEL_PARAMETER_REASONING_EFFORT, @@ -127,6 +135,18 @@ def _gemini_reasoning_effort_supported(model: str | None) -> bool: return lowered == "gemini-3.7-flash" or lowered.startswith("gemini-3.7-flash-") +def _meta_muse_reasoning_effort_supported(model: str | None) -> bool: + """Return whether this integration's fixed Muse model supports effort.""" + + model_id = _clean_text(model) + if not model_id: + return False + lowered = model_id.lower() + if lowered.startswith("meta:"): + lowered = lowered.split(":", 1)[1].strip() + return lowered == "muse-spark-1.3" + + def _parameter_capability( *, parameter_id: str, @@ -175,13 +195,19 @@ def _provider_default_reasoning_effort_capability( gemini_supported = provider == "gemini" and _gemini_reasoning_effort_supported( model ) - supported = openai_supported or gemini_supported + meta_muse_supported = ( + provider == "meta" and _meta_muse_reasoning_effort_supported(model) + ) + supported = openai_supported or gemini_supported or meta_muse_supported if openai_supported: allowed_values = list(OPENAI_REASONING_EFFORT_VALUES) provider_api_mapping: Mapping[str, Any] = _OPENAI_REASONING_EFFORT_API_MAPPINGS elif gemini_supported: allowed_values = list(GEMINI_REASONING_EFFORT_VALUES) provider_api_mapping = _GEMINI_REASONING_EFFORT_API_MAPPINGS + elif meta_muse_supported: + allowed_values = list(META_MUSE_REASONING_EFFORT_VALUES) + provider_api_mapping = _META_MUSE_REASONING_EFFORT_API_MAPPINGS else: allowed_values = [] provider_api_mapping = {} @@ -491,6 +517,44 @@ def openai_responses_kwargs_from_model_parameters( ) +def responses_kwargs_from_model_parameters( + raw: Any, + *, + provider: str, + model: str | None, + profile_concept_id: str | None = None, + include_registry: bool = True, +) -> dict[str, Any]: + """Project neutral parameters onto an OpenAI-compatible Responses surface.""" + + return provider_kwargs_from_model_parameters( + raw, + provider=provider, + model=model, + api_surface="responses", + include_registry=include_registry, + profile_concept_id=profile_concept_id, + ) + + +def meta_responses_kwargs_from_model_parameters( + raw: Any, + *, + model: str | None, + profile_concept_id: str | None = None, + include_registry: bool = False, +) -> dict[str, Any]: + """Project Von's neutral reasoning effort onto Meta Responses.""" + + return responses_kwargs_from_model_parameters( + raw, + provider="meta", + model=model, + profile_concept_id=profile_concept_id, + include_registry=include_registry, + ) + + def openai_chat_completions_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 726b45897..f4cd429e1 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", "openrouter", "anthropic", "gemini", "ollama", "deepseek"} + {"openai", "openrouter", "anthropic", "gemini", "meta", "ollama", "deepseek"} ) PARAMETER_ACTION_OMIT = "omit" PARAMETER_ACTION_FIXED_VALUE = "fixed_value" diff --git a/src/backend/workflows/durable/durable_executor.py b/src/backend/workflows/durable/durable_executor.py index fa30a59b5..aae16cddb 100644 --- a/src/backend/workflows/durable/durable_executor.py +++ b/src/backend/workflows/durable/durable_executor.py @@ -330,12 +330,14 @@ def _infer_client_type_from_model(model_name: str | None) -> str | None: ("gpt-", "o1-", "text-", "davinci", "curie", "babbage", "ada") ): return "openai" + if lowered.startswith("meta:") or lowered == "muse-spark-1.3": + return "meta" + if lowered.startswith("gemini"): + return "gemini" if lowered.startswith("ollama:") or ( ":" in lowered and not lowered.startswith("ft:") ): return "ollama" - if lowered.startswith("gemini"): - return "gemini" return None @@ -815,12 +817,17 @@ def _sync_exact_snapshot_trace_metadata() -> None: ("gpt-", "o1-", "text-", "davinci", "curie", "babbage", "ada") ): resolved_provider = "openai" + elif lowered_model.startswith("gemini"): + resolved_provider = "gemini" + elif ( + lowered_model.startswith("meta:") + or lowered_model == "muse-spark-1.3" + ): + resolved_provider = "meta" elif lowered_model.startswith("ollama:") or ( ":" in lowered_model and not lowered_model.startswith("ft:") ): resolved_provider = "ollama" - elif lowered_model.startswith("gemini"): - resolved_provider = "gemini" if resolved_provider: trace.metadata["default_provider"] = resolved_provider persisted_execution_trace_id: str | None = None diff --git a/src/backend/workflows/model_execution_budget_policy.py b/src/backend/workflows/model_execution_budget_policy.py index de15699ac..9aa488f92 100644 --- a/src/backend/workflows/model_execution_budget_policy.py +++ b/src/backend/workflows/model_execution_budget_policy.py @@ -79,7 +79,15 @@ 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", "openrouter", "anthropic", "gemini", "ollama", "deepseek"}: + if provider_token in { + "openai", + "openrouter", + "anthropic", + "gemini", + "meta", + "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 3b95e864d..136f7077b 100644 --- a/src/backend/workflows/prompt_metadata_resolution.py +++ b/src/backend/workflows/prompt_metadata_resolution.py @@ -516,7 +516,15 @@ 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", "openrouter", "anthropic", "gemini", "ollama", "deepseek"}: + if provider_token in { + "openai", + "openrouter", + "anthropic", + "gemini", + "meta", + "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 2344322de..d2ed83c7a 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -457,13 +457,13 @@ function normaliseLlmExecutionContextString(value) { function normaliseLlmExecutionContextModel(value) { const clean = normaliseLlmExecutionContextString(value); if (!clean) return null; - return clean.replace(/^(?:openai|openrouter|ollama|gemini)[/:]/i, ''); + return clean.replace(/^(?:openai|openrouter|ollama|gemini|meta)[/:]/i, ''); } function inferLlmProviderFromModelReference(value) { const clean = normaliseLlmExecutionContextString(value); if (!clean) return null; - const matched = clean.match(/^(openai|openrouter|ollama|gemini)[/:]/i); + const matched = clean.match(/^(openai|openrouter|ollama|gemini|meta)[/:]/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 503157f0e..47229bccf 100644 --- a/src/frontend/web/von_interface/static/js/domUtils.js +++ b/src/frontend/web/von_interface/static/js/domUtils.js @@ -704,7 +704,7 @@ function normaliseModelNameForComparison(name) { if (!name || typeof name !== 'string') return ''; return name .trim() - .replace(/^(?:openai|openrouter|ollama|gemini)[/:]/i, '') + .replace(/^(?:openai|openrouter|ollama|gemini|meta)[/:]/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 8f2de097f..e2132ec35 100644 --- a/src/frontend/web/von_interface/static/js/settings.js +++ b/src/frontend/web/von_interface/static/js/settings.js @@ -96,9 +96,15 @@ export async function loadAvailableOpenRouterModels() { return data; } +export async function loadAvailableMetaModels() { + const { data } = await getJsonDetailed('/api/settings/models/meta'); + return data; +} + function premiumProviderLabel(provider) { if (provider === 'gemini') return 'Gemini'; if (provider === 'openrouter') return 'OpenRouter'; + if (provider === 'meta') return 'Meta Muse'; return 'OpenAI'; } @@ -154,6 +160,12 @@ export function renderOpenRouterModelOptions(selectElementId, models = [], selec renderPremiumModelSelect(select, 'openrouter', models, selectedModel); } +export function renderMetaModelOptions(selectElementId, models = [], selectedModel = null) { + const select = document.getElementById(selectElementId); + if (!select) return; + renderPremiumModelSelect(select, 'meta', models, selectedModel); +} + export async function loadAvailableOrganisations() { const { data } = await getJsonDetailed('/api/settings/organisations'); return data; @@ -367,6 +379,24 @@ export async function populateOpenRouterModelDropdown(selectElementId, selectedM } } +export async function populateMetaModelDropdown(selectElementId, selectedModel = null) { + const select = document.getElementById(selectElementId); + if (!select) return; + + try { + const models = await loadAvailableMetaModels(); + renderPremiumModelSelect(select, 'meta', models, selectedModel); + clearSelectRetryState(select); + } catch (err) { + console.error('Error populating Meta Muse model dropdown:', err); + renderRetryableSelectFailure(select, { + error: err, + fallbackMessage: 'Meta Muse models are temporarily unavailable.', + retryAction: () => populateMetaModelDropdown(selectElementId, selectedModel) + }); + } +} + export async function populateOrganisationsDropdown(selectElementId, selectedOrgId = 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 e6f328e0d..81e009404 100644 --- a/src/frontend/web/von_interface/static/js/settingsPage.js +++ b/src/frontend/web/von_interface/static/js/settingsPage.js @@ -17,9 +17,11 @@ import { loadOllamaHosts, populateModelDropdown, populateGeminiModelDropdown, + populateMetaModelDropdown, populateOpenRouterModelDropdown, populateOpenAIModelDropdown, renderGeminiModelOptions, + renderMetaModelOptions, renderOpenRouterModelOptions, renderOpenAIModelOptions, populateOrganisationsDropdown, @@ -62,6 +64,8 @@ import { clearStoredOllamaSelection, getStoredGeminiModelParameters, getStoredGeminiSelectedModel, + getStoredMetaModelParameters, + getStoredMetaSelectedModel, getStoredPremiumModelProvider, getEffectiveLocalModelPreference, getStoredOpenAiModelParameters, @@ -73,6 +77,8 @@ import { setLocalPremiumModelUseEnabled, setStoredGeminiModelParameters, setStoredGeminiSelectedModel, + setStoredMetaModelParameters, + setStoredMetaSelectedModel, setStoredOllamaSelection, setStoredOpenAiModelParameters, setStoredOpenAiSelectedModel, @@ -158,6 +164,8 @@ let latestGeminiModelProbe = null; let latestGeminiModelParameterCapability = null; let latestOpenRouterModelProbe = null; let latestOpenRouterModelParameterCapability = null; +let latestMetaModelProbe = null; +let latestMetaModelParameterCapability = null; let openAiModelCostSummaryGeneration = 0; let latestOllamaModelProbe = null; let latestSettingsAuthStatus = null; @@ -456,6 +464,22 @@ const PREMIUM_PROVIDER_CONFIG = Object.freeze({ testEndpoint: '/api/settings/openrouter/test_model', apiSurface: 'chat_completions', }), + meta: Object.freeze({ + label: 'Meta Muse', + envInputId: 'metaApiKeyEnvVar', + verifyButtonId: 'verifyMetaApiKeyButton', + providerContainerId: 'metaProviderSettings', + modelsContainerId: 'metaModelsContainer', + modelSelectId: 'metaModelSelect', + testButtonId: 'testMetaModelButton', + addButtonId: 'addMetaToWorkflowPoolButton', + eligibilityStatusId: 'metaModelEligibilityStatus', + modelStatusId: 'metaModelStatusMessage', + providerStatusId: 'metaStatusMessage', + verifyEndpoint: '/api/settings/meta/verify', + testEndpoint: '/api/settings/meta/test_model', + apiSurface: 'responses', + }), }); function normalisePremiumProvider(value) { @@ -475,12 +499,14 @@ function selectedPremiumProvider() { function getStoredPremiumSelectedModel(provider) { if (provider === 'gemini') return getStoredGeminiSelectedModel(); if (provider === 'openrouter') return getStoredOpenRouterSelectedModel(); + if (provider === 'meta') return getStoredMetaSelectedModel(); return getStoredOpenAiSelectedModel(); } function setStoredPremiumSelectedModel(provider, model) { if (provider === 'gemini') setStoredGeminiSelectedModel(model); else if (provider === 'openrouter') setStoredOpenRouterSelectedModel(model); + else if (provider === 'meta') setStoredMetaSelectedModel(model); else setStoredOpenAiSelectedModel(model); } @@ -489,6 +515,8 @@ function renderPremiumModelOptions(provider, selectId, models, selectedModel) { renderGeminiModelOptions(selectId, models, selectedModel); } else if (provider === 'openrouter') { renderOpenRouterModelOptions(selectId, models, selectedModel); + } else if (provider === 'meta') { + renderMetaModelOptions(selectId, models, selectedModel); } else { renderOpenAIModelOptions(selectId, models, selectedModel); } @@ -501,6 +529,9 @@ async function populatePremiumModelDropdown(provider, selectId, selectedModel) { if (provider === 'openrouter') { return populateOpenRouterModelDropdown(selectId, selectedModel); } + if (provider === 'meta') { + return populateMetaModelDropdown(selectId, selectedModel); + } return populateOpenAIModelDropdown(selectId, selectedModel); } @@ -516,7 +547,7 @@ function getSettingsConcernModelSnapshot() { const premiumToggle = document.getElementById('enableOpenAiPremiumToggle'); const premiumEnabled = premiumToggle ? !!premiumToggle.checked - : ['openai', 'openrouter', 'gemini'].includes(localModelPreference.activeSource); + : ['openai', 'openrouter', 'gemini', 'meta'].includes(localModelPreference.activeSource); const premiumProvider = selectedPremiumProvider(); const premiumModel = selectedPremiumModelName(premiumProvider); const ollamaSelection = resolveOllamaSelection(false) || localModelPreference.ollamaSelection || null; @@ -580,7 +611,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, #openrouterModelSelect, #geminiModelSelect', + focusSelector: '#globalModelSelect, #premiumProviderSelect, #openaiModelSelect, #openrouterModelSelect, #geminiModelSelect, #metaModelSelect', source: 'temporary-placeholder', }; } @@ -1032,6 +1063,7 @@ function resolveDisplayedProviderModels(settings) { let currentOpenAIModel = null; let currentOpenRouterModel = null; let currentGeminiModel = null; + let currentMetaModel = null; for (const entry of enabledLlms) { if (!entry || typeof entry !== 'object') continue; @@ -1043,6 +1075,8 @@ function resolveDisplayedProviderModels(settings) { currentOpenRouterModel = entry.model; } else if (entry.provider === 'gemini' && entry.model && !currentGeminiModel) { currentGeminiModel = entry.model; + } else if (entry.provider === 'meta' && entry.model && !currentMetaModel) { + currentMetaModel = entry.model; } } @@ -1054,6 +1088,8 @@ function resolveDisplayedProviderModels(settings) { currentOpenRouterModel = effectiveLlm.model; } else if (effectiveLlm?.provider === 'gemini' && effectiveLlm.model) { currentGeminiModel = effectiveLlm.model; + } else if (effectiveLlm?.provider === 'meta' && effectiveLlm.model) { + currentMetaModel = effectiveLlm.model; } return { @@ -1062,6 +1098,7 @@ function resolveDisplayedProviderModels(settings) { currentOpenAIModel, currentOpenRouterModel, currentGeminiModel, + currentMetaModel, }; } @@ -1254,6 +1291,7 @@ function setInlineStatusMessage(element, text, tone = null) { function getStoredPremiumModelParameters(provider) { if (provider === 'gemini') return getStoredGeminiModelParameters(); if (provider === 'openrouter') return getStoredOpenRouterModelParameters(); + if (provider === 'meta') return getStoredMetaModelParameters(); return getStoredOpenAiModelParameters(); } @@ -1262,6 +1300,8 @@ function setStoredPremiumModelParameters(provider, parameters) { setStoredGeminiModelParameters(parameters); } else if (provider === 'openrouter') { setStoredOpenRouterModelParameters(parameters); + } else if (provider === 'meta') { + setStoredMetaModelParameters(parameters); } else { setStoredOpenAiModelParameters(parameters); } @@ -1270,6 +1310,7 @@ function setStoredPremiumModelParameters(provider, parameters) { function getLatestPremiumModelParameterCapability(provider) { if (provider === 'gemini') return latestGeminiModelParameterCapability; if (provider === 'openrouter') return latestOpenRouterModelParameterCapability; + if (provider === 'meta') return latestMetaModelParameterCapability; return latestOpenAiModelParameterCapability; } @@ -1278,6 +1319,8 @@ function setLatestPremiumModelParameterCapability(provider, capability) { latestGeminiModelParameterCapability = capability; } else if (provider === 'openrouter') { latestOpenRouterModelParameterCapability = capability; + } else if (provider === 'meta') { + latestMetaModelParameterCapability = capability; } else { latestOpenAiModelParameterCapability = capability; } @@ -1369,6 +1412,7 @@ async function refreshOpenAiReasoningEffortControls() { function latestPremiumModelProbe(provider) { if (provider === 'gemini') return latestGeminiModelProbe; if (provider === 'openrouter') return latestOpenRouterModelProbe; + if (provider === 'meta') return latestMetaModelProbe; return latestOpenAiModelProbe; } @@ -1377,6 +1421,8 @@ function setLatestPremiumModelProbe(provider, probe) { latestGeminiModelProbe = probe; } else if (provider === 'openrouter') { latestOpenRouterModelProbe = probe; + } else if (provider === 'meta') { + latestMetaModelProbe = probe; } else { latestOpenAiModelProbe = probe; } @@ -1662,6 +1708,10 @@ async function testSelectedOpenRouterModel() { return testSelectedPremiumModel('openrouter'); } +async function testSelectedMetaModel() { + return testSelectedPremiumModel('meta'); +} + async function testSelectedOllamaModel() { const selection = resolveOllamaSelection(false); const selectedModel = String(selection?.model || selection?.value || '').trim(); @@ -2066,6 +2116,10 @@ function selectedOpenRouterPoolEntry() { return selectedPremiumPoolEntry('openrouter'); } +function selectedMetaPoolEntry() { + return selectedPremiumPoolEntry('meta'); +} + function isPersistedEffectiveModelAllowed(entry) { const canonical = buildCanonicalLlmEntry(entry); return Boolean( @@ -2381,6 +2435,7 @@ function renderWorkflowModelPool() { updatePoolActionButton('addOpenAiToWorkflowPoolButton', selectedOpenAiPoolEntry()); updatePoolActionButton('addOpenRouterToWorkflowPoolButton', selectedOpenRouterPoolEntry()); updatePoolActionButton('addGeminiToWorkflowPoolButton', selectedGeminiPoolEntry()); + updatePoolActionButton('addMetaToWorkflowPoolButton', selectedMetaPoolEntry()); updatePoolActionButton('addOllamaToWorkflowPoolButton', selectedOllamaPoolEntry()); updateScopedPrimaryActionButtons(); updateSelectedPremiumEligibilityControls(); @@ -2447,6 +2502,7 @@ function renderWorkflowModelPool() { updatePoolActionButton('addOpenAiToWorkflowPoolButton', selectedOpenAiPoolEntry()); updatePoolActionButton('addOpenRouterToWorkflowPoolButton', selectedOpenRouterPoolEntry()); updatePoolActionButton('addGeminiToWorkflowPoolButton', selectedGeminiPoolEntry()); + updatePoolActionButton('addMetaToWorkflowPoolButton', selectedMetaPoolEntry()); updatePoolActionButton('addOllamaToWorkflowPoolButton', selectedOllamaPoolEntry()); updateScopedPrimaryActionButtons(); updateSelectedPremiumEligibilityControls(); @@ -5012,6 +5068,9 @@ document.addEventListener('DOMContentLoaded', async () => { document.getElementById('openrouterModelSelect')?.addEventListener('change', () => { void handlePremiumModelSelectionChange('openrouter'); }); + document.getElementById('metaModelSelect')?.addEventListener('change', () => { + void handlePremiumModelSelectionChange('meta'); + }); document.getElementById('openaiReasoningEffortSelect')?.addEventListener('change', async () => { const provider = selectedPremiumProvider(); setStoredPremiumModelParameters(provider, readPremiumModelParametersFromUi(provider)); @@ -5146,9 +5205,13 @@ document.getElementById('verifyGeminiApiKeyButton')?.addEventListener('click', ( document.getElementById('verifyOpenRouterApiKeyButton')?.addEventListener('click', () => { verifyOpenRouterApiKey(); }); +document.getElementById('verifyMetaApiKeyButton')?.addEventListener('click', () => { + verifyMetaApiKey(); +}); document.getElementById('testOpenAiModelButton')?.addEventListener('click', testSelectedOpenAiModel); document.getElementById('testGeminiModelButton')?.addEventListener('click', testSelectedGeminiModel); document.getElementById('testOpenRouterModelButton')?.addEventListener('click', testSelectedOpenRouterModel); +document.getElementById('testMetaModelButton')?.addEventListener('click', testSelectedMetaModel); document.getElementById('testOllamaModelButton')?.addEventListener('click', testSelectedOllamaModel); document.getElementById('addOpenAiToWorkflowPoolButton')?.addEventListener('click', () => { addOrUpdateWorkflowPoolEntry(selectedOpenAiPoolEntry()); @@ -5159,6 +5222,9 @@ document.getElementById('addGeminiToWorkflowPoolButton')?.addEventListener('clic document.getElementById('addOpenRouterToWorkflowPoolButton')?.addEventListener('click', () => { addOrUpdateWorkflowPoolEntry(selectedOpenRouterPoolEntry()); }); +document.getElementById('addMetaToWorkflowPoolButton')?.addEventListener('click', () => { + addOrUpdateWorkflowPoolEntry(selectedMetaPoolEntry()); +}); document.getElementById('addOllamaToWorkflowPoolButton')?.addEventListener('click', () => { addOrUpdateWorkflowPoolEntry(selectedOllamaPoolEntry()); }); @@ -5477,6 +5543,7 @@ async function loadAndDisplaySettings() { currentOpenAIModel, currentOpenRouterModel, currentGeminiModel, + currentMetaModel, } = resolveDisplayedProviderModels(settings); currentEffectiveLlm = settings.effective_llm || settings.resolved_llm || null; currentEffectiveEnabledLlms = normaliseEnabledLlmEntries( @@ -5489,14 +5556,17 @@ async function loadAndDisplaySettings() { const preferredOpenAiModel = currentOpenAIModel || localModelPreference.openaiModel || null; const preferredOpenRouterModel = currentOpenRouterModel || localModelPreference.openrouterModel || null; const preferredGeminiModel = currentGeminiModel || localModelPreference.geminiModel || null; - const premiumEnabled = ['openai', 'openrouter', 'gemini'].includes(localModelPreference.activeSource); + const preferredMetaModel = currentMetaModel || localModelPreference.metaModel || null; + const premiumEnabled = ['openai', 'openrouter', 'gemini', 'meta'].includes(localModelPreference.activeSource); const preferredPremiumProvider = normalisePremiumProvider( localModelPreference.premiumProvider || localModelPreference.activeSource, ) || normalisePremiumProvider(displayedEffectiveLlm?.provider) || (currentOpenRouterModel && !currentOpenAIModel ? 'openrouter' - : currentGeminiModel && !currentOpenAIModel ? 'gemini' : 'openai'); + : currentGeminiModel && !currentOpenAIModel + ? 'gemini' + : currentMetaModel && !currentOpenAIModel ? 'meta' : 'openai'); const premiumProviderSelect = document.getElementById('premiumProviderSelect'); if (premiumProviderSelect) premiumProviderSelect.value = preferredPremiumProvider; syncPremiumProviderPanels(); @@ -5544,6 +5614,15 @@ async function loadAndDisplaySettings() { } catch (error) { console.log('Could not directly load OpenRouter models, will need verification:', error.message); } + } else if (preferredPremiumProvider === 'meta' && preferredMetaModel) { + try { + await populateMetaModelDropdown('metaModelSelect', preferredMetaModel); + const container = document.getElementById('metaModelsContainer'); + if (container) { container.style.display = 'block'; container.classList.remove('hidden'); } + await refreshPremiumReasoningEffortControls('meta'); + } catch (error) { + console.log('Could not directly load Meta Muse models, will need verification:', error.message); + } } // Restore missing org/language context from persisted per-user preferences @@ -5681,6 +5760,10 @@ async function loadAndDisplaySettings() { if (openrouterEnvVarInput && settings.openrouter_api_key_env_var) { openrouterEnvVarInput.value = settings.openrouter_api_key_env_var; } + const metaEnvVarInput = document.getElementById('metaApiKeyEnvVar'); + if (metaEnvVarInput) { + metaEnvVarInput.value = 'META_API_KEY'; + } const openAiPremiumToggle = document.getElementById('enableOpenAiPremiumToggle'); if (openAiPremiumToggle) { openAiPremiumToggle.checked = premiumEnabled; @@ -5694,10 +5777,14 @@ async function loadAndDisplaySettings() { if (preferredOpenRouterModel) { setStoredOpenRouterSelectedModel(preferredOpenRouterModel); } + if (preferredMetaModel) { + setStoredMetaSelectedModel(preferredMetaModel); + } setStoredPremiumModelProvider(preferredPremiumProvider); latestOpenAiModelProbe = null; latestGeminiModelProbe = null; latestOpenRouterModelProbe = null; + latestMetaModelProbe = null; latestOllamaModelProbe = null; updateOpenAiModelStatusMessage(); updateOllamaModelStatusMessage(); @@ -5884,7 +5971,9 @@ async function loadAndDisplaySettings() { ? preferredGeminiModel : preferredPremiumProvider === 'openrouter' ? preferredOpenRouterModel - : preferredOpenAiModel, + : preferredPremiumProvider === 'meta' + ? preferredMetaModel + : preferredOpenAiModel, ); } @@ -6287,6 +6376,10 @@ export async function checkOpenRouterEnvVar() { return checkPremiumProviderEnvVar('openrouter'); } +export async function checkMetaEnvVar() { + return checkPremiumProviderEnvVar('meta'); +} + async function verifyPremiumApiKey(providerName, savedModel = null) { const provider = normalisePremiumProvider(providerName) || 'openai'; const config = PREMIUM_PROVIDER_CONFIG[provider]; @@ -6299,7 +6392,9 @@ async function verifyPremiumApiKey(providerName, savedModel = null) { setInlineStatusMessage( statusMessage, - `Verifying ${config.label} API key and loading models...`, + provider === 'meta' + ? 'Checking Meta API key configuration and loading supported models...' + : `Verifying ${config.label} API key and loading models...`, null, ); @@ -6309,7 +6404,9 @@ async function verifyPremiumApiKey(providerName, savedModel = null) { if (response.success) { setInlineStatusMessage( statusMessage, - `${config.label} API key verified. Models loaded. Test the selected model before relying on it.`, + provider === 'meta' + ? 'Meta API key configuration found. Supported models loaded. Test the selected model before relying on it.' + : `${config.label} API key verified. Models loaded. Test the selected model before relying on it.`, null, ); if (modelsContainer) { @@ -6382,6 +6479,10 @@ async function verifyOpenRouterApiKey(savedModel = null) { return verifyPremiumApiKey('openrouter', savedModel); } +async function verifyMetaApiKey(savedModel = null) { + return verifyPremiumApiKey('meta', 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 068b77f1b..36e2d31a8 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', 'openrouter', 'gemini']); +const PREMIUM_MODEL_PROVIDERS = new Set(['openai', 'openrouter', 'gemini', 'meta']); function readStoredJson(key) { try { @@ -116,6 +116,8 @@ function buildCanonicalLocalModelPreference({ geminiModelParameters = null, openrouterModel = null, openrouterModelParameters = null, + metaModel = null, + metaModelParameters = null, ollamaSelection = null, } = {}) { const normalisedActiveSource = normaliseActiveSource(activeSource); @@ -124,6 +126,7 @@ function buildCanonicalLocalModelPreference({ const normalisedOpenAiParameters = normaliseModelParameters(openaiModelParameters); const normalisedGeminiParameters = normaliseModelParameters(geminiModelParameters); const normalisedOpenRouterParameters = normaliseModelParameters(openrouterModelParameters); + const normalisedMetaParameters = normaliseModelParameters(metaModelParameters); const canonical = { schemaVersion: LOCAL_MODEL_PREFERENCE_SCHEMA, activeSource: normalisedActiveSource, @@ -152,6 +155,13 @@ function buildCanonicalLocalModelPreference({ if (normalisedOpenRouterParameters) { canonical.openrouterModelParameters = normalisedOpenRouterParameters; } + const normalisedMetaModel = normaliseLocalModelName(metaModel); + if (normalisedMetaModel) { + canonical.metaModel = normalisedMetaModel; + } + if (normalisedMetaParameters) { + canonical.metaModelParameters = normalisedMetaParameters; + } if ( !canonical.activeSource @@ -159,6 +169,7 @@ function buildCanonicalLocalModelPreference({ && !canonical.openaiModel && !canonical.geminiModel && !canonical.openrouterModel + && !canonical.metaModel && !canonical.ollamaSelection ) { return null; @@ -192,6 +203,12 @@ function cloneLocalModelPreference(preference) { ...(normaliseModelParameters(preference.openrouterModelParameters) ? { openrouterModelParameters: normaliseModelParameters(preference.openrouterModelParameters) } : {}), + ...(normaliseLocalModelName(preference.metaModel) + ? { metaModel: normaliseLocalModelName(preference.metaModel) } + : {}), + ...(normaliseModelParameters(preference.metaModelParameters) + ? { metaModelParameters: normaliseModelParameters(preference.metaModelParameters) } + : {}), ollamaSelection: normaliseOllamaSelection(preference.ollamaSelection), }; } @@ -221,6 +238,12 @@ function normaliseStoredLocalModelPreference(stored) { ?? stored.openrouter_model_parameters ?? null ), + metaModel: stored.metaModel ?? stored.meta_model ?? null, + metaModelParameters: ( + stored.metaModelParameters + ?? stored.meta_model_parameters + ?? null + ), ollamaSelection: stored.ollamaSelection ?? stored.ollama_selection ?? null, }); } @@ -347,12 +370,16 @@ function buildEffectiveLocalModelPreference(preference) { ? canonical.geminiModel : provider === 'openrouter' ? canonical.openrouterModel - : canonical.openaiModel, + : provider === 'meta' + ? canonical.metaModel + : canonical.openaiModel, provider === 'gemini' ? canonical.geminiModelParameters : provider === 'openrouter' ? canonical.openrouterModelParameters - : canonical.openaiModelParameters, + : provider === 'meta' + ? canonical.metaModelParameters + : canonical.openaiModelParameters, ); } else if (canonical.activeSource === 'ollama') { requestedLlm = buildOllamaRequestedLlm(canonical.ollamaSelection); @@ -590,6 +617,48 @@ export function setStoredOpenRouterModelParameters(modelParameters) { persistLocalModelPreference(next); } +export function getStoredMetaSelectedModel() { + return getEffectiveLocalModelPreference().metaModel || ''; +} + +export function setStoredMetaSelectedModel(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.metaModel = model; + } else { + delete next.metaModel; + } + persistLocalModelPreference(next); +} + +export function getStoredMetaModelParameters() { + return getEffectiveLocalModelPreference().metaModelParameters || null; +} + +export function setStoredMetaModelParameters(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.metaModelParameters = params; + } else { + delete next.metaModelParameters; + } + 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 1f871a7f2..d03659770 100644 --- a/src/frontend/web/von_interface/templates/settings_tab.html +++ b/src/frontend/web/von_interface/templates/settings_tab.html @@ -314,6 +314,7 @@

Premium Models

+
@@ -367,6 +368,28 @@

Premium Models

+ +