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..a7131f6f5 100644 --- a/src/backend/server/routes/von_routes.py +++ b/src/backend/server/routes/von_routes.py @@ -3568,6 +3568,43 @@ def _copy_live_progress_mapping(value: Any) -> dict[str, Any] | None: return {str(key): item for key, item in value.items() if isinstance(key, str)} +def _resolve_live_llm_credential_source( + source: Mapping[str, Any], +) -> str | None: + """Project only the bounded credential provenance needed by the UI.""" + + candidate_mapping = source.get("candidate") + candidate: Mapping[str, Any] = ( + cast(Mapping[str, Any], candidate_mapping) + if isinstance(candidate_mapping, Mapping) + else {} + ) + transport_candidates = ( + source, + source.get("transport"), + source.get("transport_metadata"), + source.get("llm_transport"), + source.get("structured_tool_transport"), + candidate.get("transport_metadata"), + candidate.get("llm_transport"), + candidate.get("structured_tool_transport"), + ) + primary_observed = False + for transport in transport_candidates: + if not isinstance(transport, Mapping): + continue + credential_source = _progress_str(transport.get("credential_source")) + normalised_source = credential_source.lower() if credential_source else None + if ( + normalised_source == "backup" + or transport.get("credential_failover_used") is True + ): + return "backup" + if normalised_source == "primary": + primary_observed = True + return "primary" if primary_observed else None + + def _build_live_llm_exchange_summary( *, stage_id: str, @@ -3654,6 +3691,9 @@ def _build_live_llm_exchange_summary( selected_provider = _progress_str(source.get("provider")) if selected_provider: summary["selected_provider"] = selected_provider + credential_source = _resolve_live_llm_credential_source(source) + if credential_source: + summary["credential_source"] = credential_source fallback_attempt_no = _progress_number(source.get("fallback_attempt_no")) if fallback_attempt_no is not None: summary["fallback_attempt_no"] = int(max(0.0, fallback_attempt_no)) @@ -7500,7 +7540,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 +7587,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 +13553,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..fd11725d4 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -3,6 +3,7 @@ import { annotateTurn, fetchWithTimeout, getJsonDetailed, getUserContext, getWin import { buildLocalModelRequestFields, getEffectiveLocalModelPreference, + isPremiumModelProvider, resolveLocalRequestedLlm, } from './utils/localModelPreferences.js'; import { initializeConceptAutocomplete } from './components/conceptAutocomplete.js'; @@ -457,13 +458,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; } @@ -1072,6 +1073,34 @@ function buildLatestLlmExecutionTelemetrySummary(turnId, debugData, executionCon }; } +function syncThinkingCredentialProvenanceFromDebugData( + request, + turnId, + debugData +) { + if (!request || typeof request !== 'object') { + return null; + } + const telemetry = buildLatestLlmExecutionTelemetrySummary( + turnId, + debugData, + request.executionContextBinding || null + ); + const credentialSource = resolveThinkingCredentialSource( + telemetry?.credential_source, + telemetry + ); + if (!credentialSource) { + return null; + } + request.thinkingCredentialSource = credentialSource; + request.thinkingCredentialFailoverUsed = ( + telemetry?.credential_failover_used === true + || credentialSource === 'backup' + ); + return credentialSource; +} + function publishLatestLlmExecutionTelemetry() { const latestEntry = getLatestLlmDebugEntryForExport(); const currentBinding = synchroniseLlmExecutionContext(); @@ -5148,6 +5177,13 @@ function createThinkingCardHistorySnapshot(request) { request.llmUsageCostSummary && typeof request.llmUsageCostSummary === 'object' ) ? { ...request.llmUsageCostSummary } : null, + thinkingCredentialSource: resolveThinkingCredentialSource( + request.thinkingCredentialSource, + request.thinkingCredentialFailoverUsed === true + ? { credential_failover_used: true } + : null + ), + thinkingCredentialFailoverUsed: request.thinkingCredentialFailoverUsed === true, criticOutput: criticOutput ? cloneThinkingCriticObject(criticOutput) : null, thinkingCriticPanelOpen: request.thinkingCriticPanelOpen === true, thinkingCriticPanelId: request.thinkingCriticPanelId || null, @@ -5414,9 +5450,11 @@ function buildRetainedThinkingCardRequestFromDebugData(turnId, debugData) { // Retained Thinking details may use only the bounded source label from // this exact turn. Never carry a key, filename, or provider exception // into the presentation model. - thinkingCredentialSource: normaliseThinkingCredentialSource( - llmExecutionTelemetry?.credential_source + thinkingCredentialSource: resolveThinkingCredentialSource( + llmExecutionTelemetry?.credential_source, + llmExecutionTelemetry ), + thinkingCredentialFailoverUsed: llmExecutionTelemetry?.credential_failover_used === true, criticOutput: criticOutput ? cloneThinkingCriticObject(criticOutput) : null, thinkingCriticPanelOpen: false, thinkingCardMode: loadStoredThinkingCardMode(), @@ -7124,6 +7162,18 @@ export function __testOnly_buildRetainedThinkingCardRequestFromDebugData(turnId, return buildRetainedThinkingCardRequestFromDebugData(turnId, debugData); } +export function __testOnly_createThinkingCardHistorySnapshot(request) { + return createThinkingCardHistorySnapshot(request); +} + +export function __testOnly_syncThinkingCredentialProvenanceFromDebugData( + request, + turnId, + debugData +) { + return syncThinkingCredentialProvenanceFromDebugData(request, turnId, debugData); +} + export function __testOnly_buildThinkingDiagnosticsPayload(request = null) { return buildThinkingDiagnosticsPayload(request); } @@ -7869,6 +7919,30 @@ function normaliseThinkingCredentialSource(...values) { return null; } +function resolveThinkingCredentialSource(...values) { + let primaryObserved = false; + for (const value of values) { + if (value && typeof value === 'object') { + const source = normaliseThinkingCredentialSource(value.credential_source); + if (source === 'backup' || value.credential_failover_used === true) { + return 'backup'; + } + if (source === 'primary') { + primaryObserved = true; + } + continue; + } + const source = normaliseThinkingCredentialSource(value); + if (source === 'backup') { + return 'backup'; + } + if (source === 'primary') { + primaryObserved = true; + } + } + return primaryObserved ? 'primary' : null; +} + function normaliseThinkingCardLlmLifecycle(value, request = null) { if (!value || typeof value !== 'object') { return null; @@ -7898,13 +7972,22 @@ function normaliseThinkingCardLlmLifecycle(value, request = null) { const provider = normaliseThinkingActivityString( value.provider || value.selected_provider || value.llm_selected_provider ) || null; - const credentialSource = normaliseThinkingCredentialSource( + const lifecycleCredentialSource = resolveThinkingCredentialSource( value.credential_source, - value.transport?.credential_source, - value.transport_metadata?.credential_source, + value.llm_credential_source, + value.transport, + value.transport_metadata, + value.llm_transport, + value.structured_tool_transport + ); + const turnCredentialSource = resolveThinkingCredentialSource( + value.turn_credential_source, request?.thinkingCredentialSource, - request?.llmExecutionTelemetry?.credential_source, - request?.llm_execution_telemetry?.credential_source + request?.thinkingCredentialFailoverUsed === true + ? { credential_failover_used: true } + : null, + request?.llmExecutionTelemetry, + request?.llm_execution_telemetry ); const fallbackAttemptNo = Number.isFinite(value.fallback_attempt_no) ? Number(value.fallback_attempt_no) @@ -7920,7 +8003,8 @@ function normaliseThinkingCardLlmLifecycle(value, request = null) { && !contextSummary && !model && !provider - && !credentialSource + && !lifecycleCredentialSource + && !turnCredentialSource && fallbackAttemptNo === null && fallbackCandidateCount === null ) { @@ -7946,7 +8030,8 @@ function normaliseThinkingCardLlmLifecycle(value, request = null) { : (Number.isFinite(llmRequest?.tool_count) ? Number(llmRequest.tool_count) : null)), model, provider, - credential_source: credentialSource, + credential_source: lifecycleCredentialSource, + turn_credential_source: turnCredentialSource, prepared_at_utc: normaliseThinkingActivityString( value.prepared_at_utc || value.llm_request_prepared_at_utc ) || null, @@ -8013,9 +8098,23 @@ function buildThinkingCardLlmLifecycleText(llmLifecycle, options = {}) { if (options.includeProvider && llmLifecycle.provider) { bits.push(`Provider: ${llmLifecycle.provider}`); } + if (options.includeModelClass && isPremiumModelProvider(llmLifecycle.provider)) { + bits.push('Class: premium'); + } if (options.includeCredential && llmLifecycle.credential_source) { bits.push(`Credential: ${llmLifecycle.credential_source} key`); } + if ( + options.includeCredential + && llmLifecycle.turn_credential_source + && llmLifecycle.turn_credential_source !== llmLifecycle.credential_source + ) { + if (llmLifecycle.turn_credential_source === 'backup') { + bits.push('Backup key used in turn'); + } else if (!llmLifecycle.credential_source) { + bits.push('Primary key used in turn'); + } + } if ( Number.isFinite(llmLifecycle.fallback_attempt_no) && Number.isFinite(llmLifecycle.fallback_candidate_count) @@ -8784,6 +8883,7 @@ function renderThinkingCardProgressSynopsisHTML(viewModel, mode = THINKING_CARD_ viewModel.llm_input_lifecycle, { includeProvider: renderMode === THINKING_CARD_MODE_DEBUG, + includeModelClass: renderMode === THINKING_CARD_MODE_DEBUG, includeCredential: renderMode === THINKING_CARD_MODE_DEBUG } ); @@ -10581,6 +10681,13 @@ function buildThinkingLiveLlmExchange(stageId, stageDiagnostic, latestStageEvent : null, selected_model: normaliseThinkingActivityString(candidate.selected_model || candidate.model) || null, selected_provider: normaliseThinkingActivityString(candidate.selected_provider || candidate.provider) || null, + credential_source: resolveThinkingCredentialSource( + candidate.credential_source, + candidate.transport, + candidate.transport_metadata, + candidate.llm_transport, + candidate.structured_tool_transport + ), fallback_attempt_no: Number.isFinite(candidate.fallback_attempt_no) ? Number(candidate.fallback_attempt_no) : null, @@ -10622,6 +10729,13 @@ function buildThinkingLiveLlmExchange(stageId, stageDiagnostic, latestStageEvent : null, selected_model: normaliseThinkingActivityString(candidate.model) || null, selected_provider: normaliseThinkingActivityString(candidate.provider) || null, + credential_source: resolveThinkingCredentialSource( + candidate.credential_source, + candidate.transport, + candidate.transport_metadata, + candidate.llm_transport, + candidate.structured_tool_transport + ), fallback_attempt_no: Number.isFinite(candidate.fallback_attempt_no) ? Number(candidate.fallback_attempt_no) : null, @@ -10860,6 +10974,7 @@ function buildThinkingWorkflowStageDiagnosticData(stageId, stageLabel, request) : null, llm_selected_model: normaliseThinkingActivityString(latestLlmExchange?.selected_model) || null, llm_selected_provider: normaliseThinkingActivityString(latestLlmExchange?.selected_provider) || null, + llm_credential_source: resolveThinkingCredentialSource(latestLlmExchange), llm_fallback_attempt_no: Number.isFinite(latestLlmExchange?.fallback_attempt_no) ? Number(latestLlmExchange.fallback_attempt_no) : null, @@ -37500,6 +37615,11 @@ async function handleSendPrompt(options = {}) { reason: 'terminal_summary' }); syncThinkingCriticOutputFromSource(request, enriched); + syncThinkingCredentialProvenanceFromDebugData( + request, + assistantTurnId, + enriched + ); setLlmDebugDataEntry(assistantTurnId, enriched, request.executionContextBinding); if (isRequestVisible()) { refreshThinkingCardProgressUi(request); @@ -37581,6 +37701,11 @@ async function handleSendPrompt(options = {}) { reason: 'terminal_summary' }); syncThinkingCriticOutputFromSource(request, data.llm_debug); + syncThinkingCredentialProvenanceFromDebugData( + request, + errorTurnId, + data.llm_debug + ); setLlmDebugDataEntry(errorTurnId, data.llm_debug, request.executionContextBinding); if (isRequestVisible()) { refreshThinkingCardProgressUi(request); 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/test/chatTab.test.js b/src/frontend/web/von_interface/static/js/test/chatTab.test.js index 950ea455f..270cac8dd 100644 --- a/src/frontend/web/von_interface/static/js/test/chatTab.test.js +++ b/src/frontend/web/von_interface/static/js/test/chatTab.test.js @@ -2,6 +2,8 @@ import { __testOnly_buildThinkingProgressPresentation, __testOnly_buildThinkingCardProgressViewModel, __testOnly_buildRetainedThinkingCardRequestFromDebugData, + __testOnly_createThinkingCardHistorySnapshot, + __testOnly_syncThinkingCredentialProvenanceFromDebugData, __testOnly_buildThinkingDiagnosticsPayload, __testOnly_buildDiagnosticsExportRequestPayload, __testOnly_buildWorkflowMonitorExportPayload, @@ -4972,6 +4974,61 @@ describe('thinking activity history normalisation', () => { expect(html).toContain('Sent'); }); + test('shows premium backup-key provenance from the active live LLM exchange', () => { + const request = { + clientRequestId: 'req-live-premium-backup', + thinkingCardMode: 'debug', + promptRaw: 'use the selected premium model', + latestProgress: { + status: 'llm_call_chunk', + stage: 'plain_response', + phase: 'plain_response', + model: 'gpt-5.6-luna', + provider: 'openai', + llm_request_state: 'received_output', + llm_transport: { + credential_source: 'backup', + credential_failover_used: true, + primary_credential_failure_kind: 'quota_exhausted' + }, + llm_request: { + prompt: { + text: 'Answer using the selected premium model.', + char_count: 40 + }, + context_summary: { message_count: 2 }, + context_message_count: 2 + }, + llm_response_preview: { + text: 'The response has started.', + char_count: 25 + } + }, + workflowStagePath: { + path: [{ stage_id: 'plain_response', stage_label: 'Plain response' }] + }, + stageDiagnostics: [] + }; + + const diagnosticsPayload = __testOnly_buildThinkingDiagnosticsPayload(request); + expect(diagnosticsPayload.stage_diagnostics[0].diagnostics).toEqual( + expect.objectContaining({ + llm_selected_model: 'gpt-5.6-luna', + llm_selected_provider: 'openai', + llm_credential_source: 'backup', + latest_llm_exchange: expect.objectContaining({ + credential_source: 'backup' + }) + }) + ); + + const html = __testOnly_renderThinkingCardBodyHTML(request); + expect(html).toContain( + 'Model: gpt-5.6-luna · Provider: openai · Class: premium · Credential: backup key' + ); + expect(html).not.toContain('quota_exhausted'); + }); + test('renders full LLM call log toggle in expert mode', () => { const request = { clientRequestId: 'req-llm-log-toggle', @@ -6001,7 +6058,7 @@ describe('thinking activity history normalisation', () => { })); }); - test('shows the exact retained turn backup credential in debug LLM input details', () => { + test('shows retained turn-level backup credential use in debug LLM input details', () => { const request = __testOnly_buildRetainedThinkingCardRequestFromDebugData( 'turn-backup-credential', { @@ -6017,6 +6074,14 @@ describe('thinking activity history normalisation', () => { credential_failover_used: true, primary_credential_failure_kind: 'quota_exhausted' } + }, + { + model: 'gpt-5.6-luna', + provider: 'openai', + transport: { + credential_source: 'primary', + credential_failover_used: false + } } ] }, @@ -6039,14 +6104,63 @@ describe('thinking activity history normalisation', () => { request.thinkingCardMode = 'debug'; const html = __testOnly_renderThinkingCardBodyHTML(request); - expect(html).toContain('Model: gpt-5.6-luna'); - expect(html).toContain('Provider: openai'); - expect(html).toContain('Credential: backup key'); + expect(request.thinkingCredentialFailoverUsed).toBe(true); + expect(html).toContain( + 'Model: gpt-5.6-luna · Provider: openai · Class: premium · Backup key used in turn' + ); + expect(html).not.toContain('Credential: backup key'); expect(html).not.toContain('quota_exhausted'); }); - test('shows primary credential provenance from live lifecycle telemetry in debug mode', () => { - const html = __testOnly_renderThinkingCardBodyHTML({ + test('retains premium backup-key provenance on the immediately completed card', () => { + const request = { + clientRequestId: 'req-immediate-backup', + resultTurnId: 'turn-immediate-backup', + thinkingCardMode: 'debug', + latestProgress: { + status: 'completed', + stage: 'completed', + progress_view_model: { + llm_input_lifecycle: { + state: 'completed', + model: 'gpt-5.6-luna', + provider: 'openai' + } + } + } + }; + const debugData = { + timestamp: '2026-09-03T16:13:00Z', + llm_interaction: { + requested_model: 'gpt-5.6-luna', + calls: [{ + model: 'gpt-5.6-luna', + provider: 'openai', + transport: { + credential_source: 'backup', + credential_failover_used: true + } + }] + } + }; + + expect(__testOnly_syncThinkingCredentialProvenanceFromDebugData( + request, + request.resultTurnId, + debugData + )).toBe('backup'); + const snapshot = __testOnly_createThinkingCardHistorySnapshot(request); + const html = __testOnly_renderThinkingCardBodyHTML(snapshot); + + expect(snapshot.thinkingCredentialSource).toBe('backup'); + expect(snapshot.thinkingCredentialFailoverUsed).toBe(true); + expect(html).toContain('Class: premium'); + expect(html).toContain('Backup key used in turn'); + expect(html).not.toContain('Credential: backup key'); + }); + + test('keeps exact lifecycle credential distinct from backup use elsewhere in the turn', () => { + const request = { thinkingCardMode: 'debug', latestProgress: { status: 'completed', @@ -6060,10 +6174,66 @@ describe('thinking activity history normalisation', () => { } } } - }); + }; + expect(__testOnly_syncThinkingCredentialProvenanceFromDebugData( + request, + 'turn-multiple-credentials', + { + llm_interaction: { + requested_model: 'gemini-2.5-pro', + calls: [ + { + model: 'gpt-5.6-luna', + provider: 'openai', + transport: { + credential_source: 'backup', + credential_failover_used: true + } + }, + { + model: 'gemini-2.5-pro', + provider: 'gemini', + transport: { + credential_source: 'primary', + credential_failover_used: false + } + } + ] + } + } + )).toBe('backup'); + + const html = __testOnly_renderThinkingCardBodyHTML(request); expect(html).toContain('Provider: gemini'); + expect(html).toContain('Class: premium'); expect(html).toContain('Credential: primary key'); + expect(html).toContain('Backup key used in turn'); + expect(html).not.toContain('Credential: backup key'); + }); + + test('keeps local model fallback distinct from premium credential backup', () => { + const html = __testOnly_renderThinkingCardBodyHTML({ + thinkingCardMode: 'debug', + latestProgress: { + status: 'sent', + stage: 'plain_response', + progress_view_model: { + llm_input_lifecycle: { + state: 'sent', + model: 'gemma4:26b', + provider: 'ollama', + fallback_attempt_no: 2, + fallback_candidate_count: 2 + } + } + } + }); + + expect(html).toContain('Provider: ollama'); + expect(html).toContain('Fallback 2 of 2'); + expect(html).not.toContain('Class: premium'); + expect(html).not.toContain('Credential:'); }); test('does not render an unrecognised credential-source value', () => { 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..61dc04282 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 { @@ -45,6 +45,10 @@ function normalisePremiumProvider(value) { return PREMIUM_MODEL_PROVIDERS.has(provider) ? provider : null; } +export function isPremiumModelProvider(value) { + return normalisePremiumProvider(value) !== null; +} + function isObjectStringArtifact(value) { return /^\[object\s+[^\]]+\](?:\s|$)/i.test(value); } @@ -116,6 +120,8 @@ function buildCanonicalLocalModelPreference({ geminiModelParameters = null, openrouterModel = null, openrouterModelParameters = null, + metaModel = null, + metaModelParameters = null, ollamaSelection = null, } = {}) { const normalisedActiveSource = normaliseActiveSource(activeSource); @@ -124,6 +130,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 +159,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 +173,7 @@ function buildCanonicalLocalModelPreference({ && !canonical.openaiModel && !canonical.geminiModel && !canonical.openrouterModel + && !canonical.metaModel && !canonical.ollamaSelection ) { return null; @@ -192,6 +207,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 +242,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 +374,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 +621,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

+ +