From 196f59c7ddf25dcad40bc58543a2dbb67e223baa Mon Sep 17 00:00:00 2001 From: witbrock Date: Fri, 21 Aug 2026 19:15:21 +0200 Subject: [PATCH 1/2] feat: add Gemini 3.7 Flash premium model support --- .env.template | 3 + README.md | 7 +- docs/engineering/environment_minimums.md | 8 +- docs/engineering/openstack_deployment.md | 9 + .../workflow_model_policy_schema.md | 2 +- infra/openstack/README.md | 5 +- .../environments/dev/dev.tfvars.example | 4 + .../environments/prod/prod.tfvars.example | 4 + .../staging/staging.tfvars.example | 4 + infra/openstack/main.tf | 2 + .../cloud-init/von_bootstrap.yaml.tftpl | 14 +- infra/openstack/variables.tf | 23 + pyproject.toml | 2 +- .../integrations/internal_mcp/orchestrator.py | 348 ++++- src/backend/languagemodels/llm_interface.py | 598 ++++++-- src/backend/languagemodels/model_defaults.py | 2 +- .../providers/gemini_client.py | 1202 ++++++++++++++--- src/backend/server/routes/settings_routes.py | 470 ++++++- .../file_copy_interpretation_service.py | 29 +- .../services/llm_api_key_resolution.py | 21 +- .../services/llm_usage_cost_service.py | 81 +- .../services/model_parameter_service.py | 70 +- ...odel_registry_release_vontology_service.py | 689 ++++++++++ .../services/model_registry_service.py | 10 + ..._registry_gemini_3_7_flash_release.json.in | 254 ++++ .../web/von_interface/static/js/chatTab.js | 10 +- .../web/von_interface/static/js/domUtils.js | 1 + .../web/von_interface/static/js/settings.js | 58 +- .../von_interface/static/js/settingsPage.js | 706 +++++++--- .../static/js/utils/localModelPreferences.js | 194 ++- .../von_interface/templates/settings_tab.html | 94 +- src/workflows/von/main.py | 8 +- .../backend/test_gemini_client_current_sdk.py | 204 +++ .../test_gemini_structured_tool_transport.py | 404 ++++++ tests/backend/test_llm_api_key_resolution.py | 66 +- tests/backend/test_llm_usage_cost_service.py | 89 ++ tests/backend/test_model_defaults.py | 4 +- .../test_model_execution_eligibility.py | 187 +++ tests/backend/test_model_parameter_service.py | 40 + ...odel_registry_release_vontology_service.py | 424 ++++++ tests/backend/test_model_registry_service.py | 18 + ...t_orchestrator_model_fallback_execution.py | 398 ++++++ .../test_settings_openai_model_probe.py | 301 +++++ .../backend/test_von_main_dotenv_overrides.py | 12 + tests/frontend/chatTaskQueue.test.js | 65 + tests/frontend/localModelPreferences.test.js | 77 ++ tests/frontend/settingsGeminiModels.test.js | 48 + tests/frontend/settingsGeminiPremium.test.js | 126 ++ tests/frontend/settingsOpenAiKeyCheck.test.js | 6 +- .../infra/test_openstack_deploy_templates.py | 5 + tests/test_invoke_internal_mcp_tool.py | 10 +- utilities/invoke_internal_mcp_tool.py | 5 + 52 files changed, 6690 insertions(+), 731 deletions(-) create mode 100644 src/backend/services/model_registry_release_vontology_service.py create mode 100644 src/backend/vontology/seed_bundles/model_registry_gemini_3_7_flash_release.json.in create mode 100644 tests/backend/test_gemini_client_current_sdk.py create mode 100644 tests/backend/test_gemini_structured_tool_transport.py create mode 100644 tests/backend/test_model_registry_release_vontology_service.py create mode 100644 tests/frontend/settingsGeminiModels.test.js create mode 100644 tests/frontend/settingsGeminiPremium.test.js diff --git a/.env.template b/.env.template index 909d0ae77..ffabc5291 100644 --- a/.env.template +++ b/.env.template @@ -65,8 +65,11 @@ MONGO_ALLOW_LOCAL_FALLBACK=0 # Google Gemini API Key # Get yours at: https://ai.google.dev # Primary variable: GEMINI_API_KEY +# Managed hosts may set GEMINI_API_KEY_FILE to a root-owned secret file instead. # Legacy alias still accepted for compatibility: GOOGLE_API_KEY # GEMINI_API_KEY= +# GEMINI_API_KEY_FILE=/etc/von/secrets/gemini_api_key +# VON_DEFAULT_GEMINI_MODEL=gemini-3.7-flash # ============================================================================ # WEB SEARCH (Optional) diff --git a/README.md b/README.md index b1dc03902..d3ba06534 100644 --- a/README.md +++ b/README.md @@ -187,11 +187,12 @@ Visit `http://localhost:5000` and explore the pre-loaded concepts in the interfa **Google Gemini:** 1. Get your own API key from https://ai.google.dev -2. Set `GEMINI_API_KEY` in `.env` -3. Select Gemini model in Von's Settings panel +2. Set `GEMINI_API_KEY` in `.env` (or `GEMINI_API_KEY_FILE` on a managed host) +3. Select `gemini-3.7-flash` as the premium Gemini model in Von's Settings panel Von still accepts the legacy `GOOGLE_API_KEY` name for compatibility, but new -setups should use `GEMINI_API_KEY`. +setups should use `GEMINI_API_KEY`. Gemini 3.7 uses a stateless Interactions API +profile in Von; provider response storage is disabled. ### 5. **UI Feature Flags** diff --git a/docs/engineering/environment_minimums.md b/docs/engineering/environment_minimums.md index 3a194b2fb..536da4a1e 100644 --- a/docs/engineering/environment_minimums.md +++ b/docs/engineering/environment_minimums.md @@ -34,7 +34,13 @@ $env:OLLAMA_HOST = 'http://:11434' `GEMINI_API_KEY` is the primary Gemini variable. Von also accepts the legacy `GOOGLE_API_KEY` name for compatibility, but new setups should use -`GEMINI_API_KEY`. +`GEMINI_API_KEY`. Managed deployments can instead set +`GEMINI_API_KEY_FILE=/etc/von/secrets/gemini_api_key`; the file should be +readable only by the Von service account. The default Gemini model is +`gemini-3.7-flash` (overridable with `VON_DEFAULT_GEMINI_MODEL`). The tracked +Gemini 3.7 premium-profile release input selects the stateless Interactions API +with response storage disabled; it takes effect only after governed registry +publication and canonical read-back. ## 2. How Von decides whether MongoDB is local or remote diff --git a/docs/engineering/openstack_deployment.md b/docs/engineering/openstack_deployment.md index 90b3ef72a..a2e6c65fe 100644 --- a/docs/engineering/openstack_deployment.md +++ b/docs/engineering/openstack_deployment.md @@ -70,6 +70,8 @@ $env:TF_VAR_bootstrap_google_oauth_client_id = '' $env:TF_VAR_bootstrap_google_oauth_client_secret = '' $env:TF_VAR_bootstrap_mongo_uri = '' $env:TF_VAR_bootstrap_openai_api_key = '' +# or, for Gemini +$env:TF_VAR_bootstrap_gemini_api_key = '' ``` Set OpenStack auth profile (example): @@ -152,6 +154,13 @@ $env:TF_VAR_bootstrap_default_llm_model = 'gpt-5.5' $env:TF_VAR_bootstrap_default_llm_organisation_concept_id = '#V#university_of_auckland_strong_ai_lab' ``` +For Gemini 3.7 Flash, use the corresponding provider/model seed values: + +```powershell +$env:TF_VAR_bootstrap_default_llm_provider = 'gemini' +$env:TF_VAR_bootstrap_default_llm_model = 'gemini-3.7-flash' +``` + After deployment, check: ```bash diff --git a/docs/engineering/workflow_model_policy_schema.md b/docs/engineering/workflow_model_policy_schema.md index d36b3d018..1b3ccc69f 100644 --- a/docs/engineering/workflow_model_policy_schema.md +++ b/docs/engineering/workflow_model_policy_schema.md @@ -112,7 +112,7 @@ environment variables (see `src/backend/languagemodels/model_defaults.py`): |---|---| | `VON_DEFAULT_OLLAMA_MODEL` | `gemma4:26b` | | `VON_DEFAULT_OPENAI_MODEL` | `gpt-5.5` | -| `VON_DEFAULT_GEMINI_MODEL` | `gemini-2.0-flash` | +| `VON_DEFAULT_GEMINI_MODEL` | `gemini-3.7-flash` | When writing policy JSON, prefer `active_llm` for stages that should follow the user's primary model selection, and use explicit `provider:model` strings diff --git a/infra/openstack/README.md b/infra/openstack/README.md index c4fc90467..7b7af9473 100644 --- a/infra/openstack/README.md +++ b/infra/openstack/README.md @@ -189,6 +189,9 @@ needed for real chat/workflow execution: - `bootstrap_openai_api_key_file` defaults to `/etc/von/secrets/openai_api_key`. - OpenAI keys can be injected with `TF_VAR_bootstrap_openai_api_key` or by pre-provisioning `bootstrap_openai_api_key_file` on host. +- `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_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 @@ -196,7 +199,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. +variables. The same rule applies to `bootstrap_gemini_api_key`. Operational note: diff --git a/infra/openstack/environments/dev/dev.tfvars.example b/infra/openstack/environments/dev/dev.tfvars.example index 59eaf2294..a7145a274 100644 --- a/infra/openstack/environments/dev/dev.tfvars.example +++ b/infra/openstack/environments/dev/dev.tfvars.example @@ -92,11 +92,15 @@ bootstrap_google_oauth_enable_dynamic_redirects = false # Setting it to null here prevents runtime injection from reaching cloud-init. # bootstrap_openai_api_key = "REPLACE_WITH_LOCAL_UNTRACKED_SECRET" 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" # Optional: seed a scoped model setting after first deploy. # Set all three values together to seed the organisation default. # bootstrap_default_llm_provider = "openai" # bootstrap_default_llm_model = "gpt-5.5" +# Gemini alternative: provider = "gemini", model = "gemini-3.7-flash". # 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 568ac6db3..2c48f3295 100644 --- a/infra/openstack/environments/prod/prod.tfvars.example +++ b/infra/openstack/environments/prod/prod.tfvars.example @@ -96,11 +96,15 @@ bootstrap_google_oauth_enable_dynamic_redirects = false # Setting it to null here prevents runtime injection from reaching cloud-init. # bootstrap_openai_api_key = "REPLACE_WITH_LOCAL_UNTRACKED_SECRET" 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" # Optional: seed a scoped model setting after first deploy. # Set all three values together to seed the organisation default. # bootstrap_default_llm_provider = "openai" # bootstrap_default_llm_model = "gpt-5.5" +# Gemini alternative: provider = "gemini", model = "gemini-3.7-flash". # 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 9439a5f29..f24505383 100644 --- a/infra/openstack/environments/staging/staging.tfvars.example +++ b/infra/openstack/environments/staging/staging.tfvars.example @@ -88,11 +88,15 @@ bootstrap_google_oauth_enable_dynamic_redirects = false # Setting it to null here prevents runtime injection from reaching cloud-init. # bootstrap_openai_api_key = "REPLACE_WITH_LOCAL_UNTRACKED_SECRET" 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" # Optional: seed a scoped model setting after first deploy. # Set all three values together to seed the organisation default. # bootstrap_default_llm_provider = "openai" # bootstrap_default_llm_model = "gpt-5.5" +# Gemini alternative: provider = "gemini", model = "gemini-3.7-flash". # 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 a634a7f5f..f93815618 100644 --- a/infra/openstack/main.tf +++ b/infra/openstack/main.tf @@ -176,6 +176,8 @@ locals { google_oauth_enable_dynamic_redirects = var.bootstrap_google_oauth_enable_dynamic_redirects openai_api_key = var.bootstrap_openai_api_key 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 default_llm_provider = var.bootstrap_default_llm_provider default_llm_model = var.bootstrap_default_llm_model default_llm_organisation_concept_id = var.bootstrap_default_llm_organisation_concept_id diff --git a/infra/openstack/templates/cloud-init/von_bootstrap.yaml.tftpl b/infra/openstack/templates/cloud-init/von_bootstrap.yaml.tftpl index a6aaaee22..bc8f452b7 100644 --- a/infra/openstack/templates/cloud-init/von_bootstrap.yaml.tftpl +++ b/infra/openstack/templates/cloud-init/von_bootstrap.yaml.tftpl @@ -42,8 +42,12 @@ write_files: GOOGLE_OAUTH_STRICT_STARTUP=${google_oauth_strict_startup ? 1 : 0} 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} %{ if default_llm_provider != null && lower(default_llm_provider) == "openai" && default_llm_model != null ~} VON_DEFAULT_OPENAI_MODEL=${jsonencode(default_llm_model)} +%{ endif ~} +%{ if default_llm_provider != null && lower(default_llm_provider) == "gemini" && default_llm_model != null ~} + VON_DEFAULT_GEMINI_MODEL=${jsonencode(default_llm_model)} %{ endif ~} VON_MONGO_STRICT_STARTUP=${mongo_strict_startup ? 1 : 0} VON_MONGO_STARTUP_PROBE=${mongo_startup_probe ? 1 : 0} @@ -86,6 +90,14 @@ write_files: ${openai_api_key} %{ endif ~} +%{ if gemini_api_key != null ~} + - path: ${gemini_api_key_file} + owner: root:root + permissions: "0600" + content: | + ${gemini_api_key} +%{ endif ~} + %{ if mongo_uri != null ~} - path: ${mongo_uri_file} owner: root:root @@ -195,7 +207,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}' '${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}' '${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 0048532d3..ffb31319f 100644 --- a/infra/openstack/variables.tf +++ b/infra/openstack/variables.tf @@ -539,6 +539,29 @@ variable "bootstrap_openai_api_key_file" { } } +variable "bootstrap_gemini_api_key" { + type = string + description = "Optional inline Gemini API key for managed bootstrap secret-file injection." + default = null + sensitive = true + + validation { + condition = var.bootstrap_gemini_api_key == null || trimspace(var.bootstrap_gemini_api_key) != "" + error_message = "bootstrap_gemini_api_key must be null or non-empty." + } +} + +variable "bootstrap_gemini_api_key_file" { + type = string + description = "Path used by runtime GEMINI_API_KEY_FILE." + default = "/etc/von/secrets/gemini_api_key" + + validation { + condition = can(regex("^/", var.bootstrap_gemini_api_key_file)) + error_message = "bootstrap_gemini_api_key_file must be an absolute Linux path." + } +} + variable "bootstrap_default_llm_provider" { type = string description = "Optional provider to seed as the scoped cloud default LLM setting." diff --git a/pyproject.toml b/pyproject.toml index 6774027ef..12a90caf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,7 +136,7 @@ dependencies = [ "uvicorn>=0.38.0", "cryptography>=43.0.0", "openstacksdk>=4.0.0", - "google-genai>=1.55.0", + "google-genai>=2.6.0", "bleach>=6.3.0", "pymupdf>=1.24.10", "pytesseract>=0.3.13", diff --git a/src/backend/integrations/internal_mcp/orchestrator.py b/src/backend/integrations/internal_mcp/orchestrator.py index dbd8afa60..e372a1255 100644 --- a/src/backend/integrations/internal_mcp/orchestrator.py +++ b/src/backend/integrations/internal_mcp/orchestrator.py @@ -1658,6 +1658,49 @@ class _ModelCandidate: model_parameters: Mapping[str, Any] | None = None +class _ModelCandidateClientResolutionError(RuntimeError): + """A selected provider client could not be resolved without substitution.""" + + failure_kind = "candidate_client_resolution" + + def __init__( + self, + *, + provider: str, + model: str | None, + telemetry: Mapping[str, Any], + cause_class: str, + ) -> None: + self.provider = provider + self.model = model + self.telemetry = dict(telemetry) + self.cause_class = cause_class + super().__init__( + f"{provider} candidate client resolution failed for " + f"{model or 'the provider default model'}." + ) + + def error_record(self) -> dict[str, Any]: + return { + "candidate": dict(self.telemetry), + "model_resolved": self.model, + "error": str(self), + "error_class": type(self).__name__, + "provider_error_class": self.cause_class, + "failure_kind": self.failure_kind, + } + + def fallback_attempt(self, *, attempt_no: int) -> dict[str, Any]: + return { + "attempt_no": attempt_no, + "provider": self.provider, + "model": self.model, + "status": "failed", + "duration_ms": 0, + **self.error_record(), + } + + @dataclass(frozen=True) class _StructuredToolCandidateResolution: """Resolved structured-tool candidate set for one LLM planning call.""" @@ -13657,6 +13700,10 @@ def _parse_policy_model_candidate(value: Any) -> _ModelCandidate | None: elif candidate.lower().startswith("ft:"): provider = "openai" model = candidate.strip() + elif model_suffix.strip() == "default": + # An unsupported provider's default must not be reinterpreted + # as an Ollama name-and-tag model identifier. + return None elif _model_identifier_looks_local_ollama(candidate): provider = "ollama" model = candidate.strip() @@ -14919,6 +14966,7 @@ def _create_client_for_candidate( client = default_client resolved_model = default_model resolved_provider = None + requested_provider_client: str | None = None try: from src.backend.languagemodels.llm_interface import ( get_llm_client, @@ -14938,6 +14986,7 @@ def _create_client_for_candidate( and model_implied_provider != default_provider and model_implied_provider in {"openai", "ollama", "gemini"} ): + requested_provider_client = model_implied_provider client = get_llm_client( client_type=model_implied_provider, user_concept_id=user_concept_id, @@ -14968,7 +15017,25 @@ def _create_client_for_candidate( elif resolved_provider == "ollama": resolved_model = resolve_ollama_model_name(resolved_model) resolved_model = self._normalise_llm_model_name(resolved_model) - except Exception: + except Exception as exc: + if requested_provider_client == "gemini": + telemetry.update( + { + "provider": "gemini", + "model": default_model, + "error": "candidate_client_resolution_failed", + "error_class": type(exc).__name__, + "failure_kind": ( + _ModelCandidateClientResolutionError.failure_kind + ), + } + ) + raise _ModelCandidateClientResolutionError( + provider="gemini", + model=default_model, + telemetry=telemetry, + cause_class=type(exc).__name__, + ) from exc resolved_provider = None if resolved_provider: @@ -15058,6 +15125,24 @@ 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": + telemetry.update( + { + "provider": "gemini", + "model": model, + "error": "candidate_client_resolution_failed", + "error_class": type(exc).__name__, + "failure_kind": ( + _ModelCandidateClientResolutionError.failure_kind + ), + } + ) + raise _ModelCandidateClientResolutionError( + provider="gemini", + model=model, + telemetry=telemetry, + cause_class=type(exc).__name__, + ) from exc telemetry["error"] = str(exc) return default_client, default_model, telemetry @@ -15571,16 +15656,73 @@ def _run_llm_with_fallbacks( prepared_at_utc=request_prepared_at_utc, ), } - ) + ) for attempt_no, candidate in enumerate(candidates, start=1): - client, model_name, telemetry = self._create_client_for_candidate( - candidate, - default_client=default_client, - default_model=default_model, - user_concept_id=user_concept_id, - org_concept_id=org_concept_id, - ) + try: + client, model_name, telemetry = self._create_client_for_candidate( + candidate, + default_client=default_client, + default_model=default_model, + user_concept_id=user_concept_id, + org_concept_id=org_concept_id, + ) + except _ModelCandidateClientResolutionError as exc: + error_record = exc.error_record() + errors.append(error_record) + fallback_attempts.append(exc.fallback_attempt(attempt_no=attempt_no)) + last_exception = exc + last_failure_class = type(exc).__name__ + failure_attempt_meta = { + "fallback_attempt_no": attempt_no, + "fallback_candidate_count": total_candidates, + "model_source": candidate.source, + "requested_model": default_model, + "selected_model": exc.model, + "provider": exc.provider, + "llm_exchange_id": live_llm_exchange_id, + "call_id": self._build_live_llm_call_id( + live_llm_exchange_id, attempt_no + ), + } + if callable(emit_progress): + emit_progress( + { + "status": "llm_call_end", + "stage": stage, + "model": exc.model, + "duration_ms": 0, + "success": False, + "error": str(exc), + "error_class": type(exc).__name__, + "failure_kind": exc.failure_kind, + **_stage_extra, + **failure_attempt_meta, + **self._build_live_llm_progress_payload( + request_telemetry=request_telemetry, + request_state="failed", + prepared_at_utc=request_prepared_at_utc, + ), + } + ) + record_llm_call( + call_type="llm.generate", + model_name=exc.model, + requested_model_name=default_model, + duration_ms=0, + usage=None, + note="candidate client resolution failed; trying fallback", + stage=stage, + provider=exc.provider, + candidate=exc.telemetry, + workflow_stage_id=workflow_stage_id, + status="failed", + success=False, + error=str(exc), + error_class=type(exc).__name__, + failure_kind=exc.failure_kind, + ) + continue provider = ( str(telemetry.get("provider")).strip().lower() if isinstance(telemetry, Mapping) and telemetry.get("provider") @@ -15794,6 +15936,50 @@ def _run_llm_with_fallbacks( timeout_override_sec=timeout_override_sec, check_cancellation=check_cancellation, ) + provider_response_metadata = getattr( + client, "last_response_metadata", None + ) + if not isinstance(provider_response_metadata, Mapping): + provider_response_metadata = {} + provider_usage = provider_response_metadata.get("usage") + if not isinstance(provider_usage, Mapping): + provider_usage = None + provider_response_model = provider_response_metadata.get( + "effective_model" + ) + if not isinstance(provider_response_model, str) or not ( + provider_response_model := provider_response_model.strip() + ): + provider_response_model = None + if provider_response_model: + attempt_meta["effective_model"] = provider_response_model + attempt_meta["model_identity_source"] = "provider_response" + provider_api_surface = provider_response_metadata.get("api_surface") + if ( + isinstance(provider_api_surface, str) + and provider_api_surface.strip() + ): + attempt_meta["api_surface"] = provider_api_surface.strip() + provider_transport = provider_response_metadata.get( + "transport_metadata" + ) + if isinstance(provider_transport, Mapping) and provider_transport: + attempt_meta["llm_transport"] = dict(provider_transport) + response_telemetry = ( + dict(telemetry) if isinstance(telemetry, Mapping) else {} + ) + if provider_response_model: + response_telemetry["effective_model"] = provider_response_model + response_telemetry["model_identity_source"] = "provider_response" + if ( + isinstance(provider_api_surface, str) + and provider_api_surface.strip() + ): + response_telemetry["api_surface"] = provider_api_surface.strip() + if isinstance(provider_transport, Mapping) and provider_transport: + response_telemetry["transport_metadata"] = dict(provider_transport) + if provider_usage is not None: + response_telemetry["usage"] = dict(provider_usage) duration_ms = (time.perf_counter() - llm_start) * 1000.0 first_output_at_utc = self._utc_now_iso() response_text = str(response) @@ -15804,6 +15990,21 @@ def _run_llm_with_fallbacks( "stage": stage, "model": model_name, "chunks": 1, + "tokens_streamed": ( + provider_usage.get("visible_output_tokens") + if isinstance(provider_usage, Mapping) + and isinstance( + provider_usage.get("visible_output_tokens"), int + ) + else ( + provider_usage.get("output_tokens") + if isinstance(provider_usage, Mapping) + and isinstance( + provider_usage.get("output_tokens"), int + ) + else None + ) + ), "duration_ms": int(duration_ms), **_stage_extra, **attempt_meta, @@ -15822,7 +16023,7 @@ def _run_llm_with_fallbacks( validation_result = response_validator( response_text, model_name, - telemetry if isinstance(telemetry, Mapping) else {}, + response_telemetry, ) if isinstance(validation_result, Mapping) and validation_result: validation_failure = dict(validation_result) @@ -15875,8 +16076,12 @@ def _run_llm_with_fallbacks( call_type="llm.generate", model_name=model_name, requested_model_name=default_model, + effective_model_name=provider_response_model, + model_identity_source=( + "provider_response" if provider_response_model else None + ), duration_ms=duration_ms, - usage=None, + usage=provider_usage, note="llm.generate response failed validation; trying fallback", stage=stage, provider=( @@ -15884,7 +16089,7 @@ def _run_llm_with_fallbacks( if isinstance(telemetry, Mapping) else None ), - candidate=telemetry, + candidate=response_telemetry, workflow_stage_id=workflow_stage_id, status="failed", success=False, @@ -15907,7 +16112,7 @@ def _run_llm_with_fallbacks( prepared_at_utc=request_prepared_at_utc, sent_at_utc=request_sent_at_utc, first_output_at_utc=first_output_at_utc, - usage=None, + usage=provider_usage, extra=failure_extra, ), ) @@ -15970,8 +16175,12 @@ def _run_llm_with_fallbacks( call_type="llm.generate", model_name=model_name, requested_model_name=default_model, + effective_model_name=provider_response_model, + model_identity_source=( + "provider_response" if provider_response_model else None + ), duration_ms=duration_ms, - usage=None, + usage=provider_usage, note=("Fallback chain generate()" if errors else "llm.generate"), stage=stage, provider=( @@ -15979,7 +16188,7 @@ def _run_llm_with_fallbacks( if isinstance(telemetry, Mapping) else None ), - candidate=telemetry, + candidate=response_telemetry, workflow_stage_id=workflow_stage_id, exchange_blob_ref=self._capture_llm_exchange_blob( stage=stage, @@ -15997,7 +16206,7 @@ def _run_llm_with_fallbacks( prepared_at_utc=request_prepared_at_utc, sent_at_utc=request_sent_at_utc, first_output_at_utc=first_output_at_utc, - usage=None, + usage=provider_usage, ), ) fallback_attempts.append( @@ -16011,9 +16220,7 @@ def _run_llm_with_fallbacks( "text": str(response), "char_count": len(str(response)), }, - "candidate": ( - dict(telemetry) if isinstance(telemetry, Mapping) else None - ), + "candidate": (dict(response_telemetry)), } ) selection_metadata = self._build_stage_model_selection_metadata( @@ -16034,7 +16241,7 @@ def _run_llm_with_fallbacks( "policy_stage": candidate_stage, "request": request_telemetry, "selected": { - **telemetry, + **response_telemetry, "model_resolved": model_name, }, "fallback_used": bool(errors), @@ -16046,14 +16253,16 @@ def _run_llm_with_fallbacks( "skipped_candidate_count": len(skipped_candidates), "requested_model": default_model, "selected_model": model_name, - "effective_model": None, - "model_identity_source": None, + "effective_model": provider_response_model, + "model_identity_source": ( + "provider_response" if provider_response_model else None + ), "model_source": candidate.source, **_stage_extra, **selection_metadata, } ) - return response, model_name, telemetry + return response, model_name, response_telemetry except CancellationRequested as exc: duration_ms = (time.perf_counter() - llm_start) * 1000.0 exc_text = str(exc) @@ -16875,16 +17084,73 @@ def _run_llm_with_tools_fallbacks( prepared_at_utc=request_prepared_at_utc, ), } - ) + ) for attempt_no, candidate in enumerate(candidates, start=1): - client, model_name, telemetry = self._create_client_for_candidate( - candidate, - default_client=default_client, - default_model=default_model, - user_concept_id=user_concept_id, - org_concept_id=org_concept_id, - ) + try: + client, model_name, telemetry = self._create_client_for_candidate( + candidate, + default_client=default_client, + default_model=default_model, + user_concept_id=user_concept_id, + org_concept_id=org_concept_id, + ) + except _ModelCandidateClientResolutionError as exc: + error_record = exc.error_record() + errors.append(error_record) + fallback_attempts.append(exc.fallback_attempt(attempt_no=attempt_no)) + last_exception = exc + last_failure_class = type(exc).__name__ + failure_attempt_meta = { + "fallback_attempt_no": attempt_no, + "fallback_candidate_count": total_candidates, + "model_source": candidate.source, + "requested_model": default_model, + "selected_model": exc.model, + "provider": exc.provider, + "llm_exchange_id": live_llm_exchange_id, + "call_id": self._build_live_llm_call_id( + live_llm_exchange_id, attempt_no + ), + } + if callable(emit_progress): + emit_progress( + { + "status": "llm_call_end", + "stage": stage, + "model": exc.model, + "duration_ms": 0, + "success": False, + "error": str(exc), + "error_class": type(exc).__name__, + "failure_kind": exc.failure_kind, + **_stage_extra, + **failure_attempt_meta, + **self._build_live_llm_progress_payload( + request_telemetry=request_telemetry, + request_state="failed", + prepared_at_utc=request_prepared_at_utc, + ), + } + ) + record_llm_call( + call_type="llm.generate_with_tools", + model_name=exc.model, + requested_model_name=default_model, + duration_ms=0, + usage=None, + note="candidate client resolution failed; trying fallback", + stage=stage, + provider=exc.provider, + candidate=exc.telemetry, + workflow_stage_id=workflow_stage_id, + status="failed", + success=False, + error=str(exc), + error_class=type(exc).__name__, + failure_kind=exc.failure_kind, + ) + continue provider = ( str(telemetry.get("provider")).strip() if isinstance(telemetry, Mapping) and telemetry.get("provider") @@ -17034,11 +17300,11 @@ def _run_llm_with_tools_fallbacks( if matched_name and matched_name not in required_available_tool_names: required_available_tool_names.append(matched_name) structured_call_kwargs: dict[str, Any] = {} + provider_llm_params = dict(model_parameters or {}) + if provider_llm_params: + structured_call_kwargs["llm_params"] = provider_llm_params if provider == "openai": structured_call_kwargs["parallel_tool_calls"] = False - provider_llm_params = dict(model_parameters or {}) - if provider_llm_params: - structured_call_kwargs["llm_params"] = provider_llm_params if tool_choice_override is not None: structured_call_kwargs["tool_choice"] = tool_choice_override elif len(required_available_tool_names) == 1: @@ -17306,11 +17572,15 @@ def _run_llm_with_tools_fallbacks( if callable(emit_progress): completion_tokens = None if isinstance(getattr(llm_response, "usage", None), Mapping): - raw_completion_tokens = llm_response.usage.get( - "completion_tokens" - ) - if isinstance(raw_completion_tokens, int): - completion_tokens = raw_completion_tokens + for usage_key in ( + "completion_tokens", + "visible_output_tokens", + "output_tokens", + ): + raw_completion_tokens = llm_response.usage.get(usage_key) + if isinstance(raw_completion_tokens, int): + completion_tokens = raw_completion_tokens + break emit_progress( { "status": "llm_call_chunk", diff --git a/src/backend/languagemodels/llm_interface.py b/src/backend/languagemodels/llm_interface.py index 16dd01b9c..68cf71a93 100644 --- a/src/backend/languagemodels/llm_interface.py +++ b/src/backend/languagemodels/llm_interface.py @@ -31,6 +31,7 @@ resolve_llm_setting, ) from ..services.model_parameter_service import ( + gemini_kwargs_from_model_parameters, openai_responses_kwargs_from_model_parameters, ) from .model_defaults import ( @@ -45,6 +46,10 @@ get_llm_client as get_structured_client, ) from .structured_tool_calling.client import resolve_safe_temperature_for_model +from .structured_tool_calling.transport import ( + sanitise_transport_telemetry_text, + sanitise_transport_telemetry_value, +) from ..utils.runtime_env import load_secret_from_env_or_file try: # Optional dependency (local model runtime) — imported lazily to avoid @@ -718,22 +723,36 @@ def resolve_openai_model_name(model: Optional[str]) -> Optional[str]: def _resolve_effective_llm_actor_scope( user_concept_id: Optional[str] = None, org_concept_id: Optional[str] = None, + *, + allow_ambient_actor_scope: bool = True, ) -> tuple[Optional[str], Optional[str]]: - """Resolve actor scope in HTTP and background workflow contexts alike.""" + """Resolve trusted actor scope in HTTP and background workflow contexts. + + Legacy identity headers remain valid compatibility inputs for reads, but + they are not authentication and cannot authorise paid external-model use. + Callers that already resolved a request actor may disable ambient lookup so + an intentionally actorless scope cannot be repopulated from request data. + """ resolved_user = user_concept_id resolved_org = org_concept_id if resolved_user and resolved_org: return resolved_user, resolved_org + if not allow_ambient_actor_scope: + return resolved_user, resolved_org try: 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, ) if not resolved_user: - resolved_user = get_effective_user_concept_id() - if not resolved_org: + candidate_user, actor_source = get_effective_user_concept_id_with_source() + if actor_source == LEGACY_IDENTITY_HEADER_ACTOR_SOURCE: + return None, resolved_org + resolved_user = candidate_user + if resolved_user and not resolved_org: resolved_org = get_effective_organisation_concept_id() except Exception: pass @@ -777,6 +796,7 @@ def assert_model_execution_allowed( model: Any, user_concept_id: Optional[str] = None, org_concept_id: Optional[str] = None, + allow_ambient_actor_scope: bool = True, ) -> Mapping[str, Any]: """Require an exact scoped allow-list match for external model execution. @@ -810,6 +830,7 @@ def assert_model_execution_allowed( resolved_user, resolved_org = _resolve_effective_llm_actor_scope( user_concept_id, org_concept_id, + allow_ambient_actor_scope=allow_ambient_actor_scope, ) if not resolved_user and not resolved_org: raise ModelExecutionEligibilityError( @@ -952,6 +973,33 @@ def _ensure_ollama_client() -> Optional["OllamaClient"]: class LLMInterface(ABC): """Abstract Base Class for Language Model interactions.""" + def _assert_model_execution_allowed( + self, + *, + provider: Any, + model: Any, + ) -> Mapping[str, Any]: + """Apply the external-model gate using any factory-bound actor scope.""" + + scope_bound = bool( + getattr(self, "_model_execution_actor_scope_bound", False) + ) + return assert_model_execution_allowed( + provider=provider, + model=model, + user_concept_id=getattr( + self, + "_model_execution_user_concept_id", + None, + ), + org_concept_id=getattr( + self, + "_model_execution_org_concept_id", + None, + ), + allow_ambient_actor_scope=not scope_bound, + ) + @abstractmethod def generate( self, @@ -1003,7 +1051,7 @@ def generate_with_tools( # Default implementation delegates to structured_tool_calling module config = self._get_structured_client_config(model) - assert_model_execution_allowed( + self._assert_model_execution_allowed( provider=config.provider, model=config.model, ) @@ -1974,7 +2022,7 @@ def generate( target_model = ( resolve_openai_model_name(model or self.DEFAULT_MODEL) or self.DEFAULT_MODEL ) - assert_model_execution_allowed( + self._assert_model_execution_allowed( provider="openai", model=target_model, ) @@ -2239,7 +2287,13 @@ class GeminiClient(LLMInterface): """Client for interacting with the Google Gemini API.""" def __init__( - self, api_key: Optional[str] = None, default_model: str = DEFAULT_GEMINI_MODEL + self, + api_key: Optional[str] = None, + default_model: str = DEFAULT_GEMINI_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 genai is None: raise ImportError( @@ -2251,38 +2305,57 @@ def __init__( "Gemini API key not provided or found in environment variables " "(GEMINI_API_KEY or legacy GOOGLE_API_KEY)." ) - try: - genai.configure(api_key=self.api_key) # type: ignore[attr-defined] - except Exception: - logger.debug( - "genai.configure not available; continuing without explicit config" - ) self.default_model = default_model - # Check if the default model exists upon initialization + 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 + ) try: - try: - self.client = genai.GenerativeModel(self.default_model) # type: ignore[attr-defined] - except Exception as e: - logger.error(f"Failed dynamic GenerativeModel init: {e}") - raise - logger.info(f"GeminiClient initialized with model: {self.default_model}") + self.client = genai.Client(api_key=self.api_key) # type: ignore[attr-defined] except Exception as e: - logger.error( - f"Failed to initialize Gemini model '{self.default_model}': {e}" - ) raise ValueError( - f"Failed to initialize Gemini model '{self.default_model}'. Check model name and API key." + "Failed to initialise the Gemini SDK client. Check the API key " + "and installed google-genai version." ) from e + self.last_response_metadata: Dict[str, Any] = {} + if self._uses_interactions(self.default_model): + self._require_interactions() + logger.info("GeminiClient initialised with model: %s", self.default_model) def _get_structured_client_config(self, model: Optional[str]) -> LLMClientConfig: """Get configuration for structured tool calling client (JVNAUTOSCI-799).""" + target_model = model or self.default_model return LLMClientConfig( - model=model or self.default_model, + model=target_model, provider="gemini", api_key=self.api_key, - temperature=0.7, + connection_id="gemini_developer_api", + deployment_id=target_model, + requested_api_surface=( + "interactions" + if self._uses_interactions(target_model) + else "gemini_generate_content" + ), + temperature=None if self._uses_interactions(target_model) else 0.7, ) + @staticmethod + def _uses_interactions(model: str) -> bool: + model_id = str(model or "").strip().lower() + if model_id.startswith("models/"): + model_id = model_id.split("/", 1)[1] + return model_id == "gemini-3.7-flash" or model_id.startswith( + "gemini-3.7-flash-" + ) + + def _require_interactions(self) -> None: + if getattr(self.client, "interactions", None) is None: + raise ImportError( + "Gemini 3.7 requires a google-genai release whose Client " + "exposes client.interactions." + ) + def generate( self, prompt: str, @@ -2291,9 +2364,8 @@ def generate( llm_params: Optional[Dict[str, Any]] = None, ) -> str: """Generate a response using the Gemini API. Raises RuntimeError on failure.""" - assert genai is not None target_model_name = model or self.default_model - assert_model_execution_allowed( + self._assert_model_execution_allowed( provider="gemini", model=target_model_name, ) @@ -2306,102 +2378,356 @@ def generate( ) try: - # Select the model - re-initialize if different from default - if target_model_name == self.default_model: - model_instance = self.client - else: - try: - model_instance = genai.GenerativeModel(target_model_name) # type: ignore[attr-defined] - logger.info(f"Using specified Gemini model: {target_model_name}") - except Exception as e: - logger.error( - f"Failed to initialize specified Gemini model '{target_model_name}': {e}" - ) - raise RuntimeError( - f"Could not initialize Gemini model '{target_model_name}': {str(e)}" - ) from e - - gemini_config_params = {} - if llm_params: - if "temperature" in llm_params: - gemini_config_params["temperature"] = llm_params["temperature"] - # Add other Gemini specific params like top_p, top_k, max_output_tokens - - generation_config = None - if gemini_config_params: - try: - generation_config = genai.types.GenerationConfig( - **gemini_config_params - ) # type: ignore[attr-defined] - except Exception: - generation_config = None - - send_kwargs: Dict[str, Any] = {} - if generation_config is not None: - send_kwargs["generation_config"] = generation_config - - # Gemini handles history differently (pass directly to start_chat) - if context and isinstance(context, list): - history = to_gemini_history(_coerce_context(context)) - chat = model_instance.start_chat(history=cast(Any, history)) - response = chat.send_message(prompt, **send_kwargs) + coerced_context = _coerce_context(context or []) + system_parts = [ + message["content"] + for message in coerced_context + if message["role"] == "system" and message["content"].strip() + ] + ordinary_context = [ + message for message in coerced_context if message["role"] != "system" + ] + if self._uses_interactions(target_model_name): + content = self._generate_interaction_text( + prompt=prompt, + context=ordinary_context, + system_instruction="\n\n".join(system_parts) or None, + model=target_model_name, + llm_params=llm_params, + ) else: - response = model_instance.generate_content(prompt, **send_kwargs) - + content = self._generate_content_text( + prompt=prompt, + context=ordinary_context, + system_instruction="\n\n".join(system_parts) or None, + model=target_model_name, + llm_params=llm_params, + ) if _should_log_llm_io(): - logger.debug("Gemini raw response: %s", response) - # Handle potential safety blocks or empty responses - if response.parts: - content = response.text - if _should_log_llm_io(): - logger.debug( - "[LLM RESPONSE][Gemini][%s]: %s", - target_model_name, - _truncate_for_log(content), - ) - return content - elif response.prompt_feedback and response.prompt_feedback.block_reason: - block_reason = response.prompt_feedback.block_reason - logger.warning(f"Gemini response blocked due to: {block_reason}") - raise RuntimeError(f"Gemini response blocked due to {block_reason}.") - else: - logger.warning( - "Gemini response was empty or blocked without specific reason." + logger.debug( + "[LLM RESPONSE][Gemini][%s]: %s", + target_model_name, + _truncate_for_log(content), ) - raise RuntimeError("Gemini returned an empty or blocked response.") - + return content except Exception as e: + safe_error = sanitise_transport_telemetry_text(str(e)) logger.error( - f"Error generating response with Gemini model {target_model_name}: {e}", - exc_info=True, + "Error generating response with Gemini model %s: %s", + target_model_name, + safe_error, + ) + raise RuntimeError(f"Gemini error: {safe_error}") from e + + def _generate_interaction_text( + self, + *, + prompt: str, + context: Sequence[LLMMessage], + system_instruction: str | None, + model: str, + llm_params: Mapping[str, Any] | None, + ) -> str: + self._require_interactions() + steps: list[dict[str, Any]] = [] + for message in context: + role = ( + "model_output" + if message["role"] in {"assistant", "model"} + else "user_input" + ) + steps.append( + { + "type": role, + "content": [{"type": "text", "text": message["content"]}], + } + ) + steps.append( + { + "type": "user_input", + "content": [{"type": "text", "text": prompt}], + } + ) + projected = gemini_kwargs_from_model_parameters( + llm_params, + model=model, + api_surface="interactions", + ) + generation_config: dict[str, Any] = {} + projected_generation = projected.get("generation_config") + if isinstance(projected_generation, Mapping): + generation_config.update(dict(projected_generation)) + request: Dict[str, Any] = { + "model": model, + "input": steps, + "store": False, + } + if generation_config: + request["generation_config"] = generation_config + if system_instruction: + request["system_instruction"] = system_instruction + response = self.client.interactions.create(**request) + actual_model = getattr(response, "model", None) + status_value = getattr(response, "status", None) + status_text = getattr(status_value, "value", status_value) + status = ( + str(status_text).strip().lower() + if status_text is not None and str(status_text).strip() + else None + ) + provider_errors = self._gemini_interaction_errors( + getattr(response, "errors", None) + ) + transport_metadata = { + "provider": "gemini", + "requested_model": model, + "effective_model": actual_model if isinstance(actual_model, str) else model, + "effective_api_surface": "interactions", + "connection_id": "gemini_developer_api", + "deployment_id": model, + "store": False, + "provider_status": status, + } + self.last_response_metadata = { + "provider": "gemini", + "api_surface": "interactions", + "requested_model": model, + "effective_model": actual_model if isinstance(actual_model, str) else model, + "store": False, + "usage": self._gemini_usage_mapping(getattr(response, "usage", None)), + "transport_metadata": transport_metadata, + "raw_response": { + "id": getattr(response, "id", None), + "model": actual_model, + "status": status, + "errors": provider_errors, + }, + } + if (status is not None and status != "completed") or provider_errors: + raise RuntimeError( + "Gemini interaction failed with provider status " + f"{status or 'unknown'}." ) - raise RuntimeError(f"Gemini error: {str(e)}") from e + output_text = getattr(response, "output_text", None) + if not isinstance(output_text, str) or not output_text.strip(): + output_text = self._interaction_output_text(getattr(response, "steps", [])) + if not output_text: + raise RuntimeError("Gemini returned an empty response.") + return output_text + + def _generate_content_text( + self, + *, + prompt: str, + context: Sequence[LLMMessage], + system_instruction: str | None, + model: str, + llm_params: Mapping[str, Any] | None, + ) -> str: + assert genai is not None + contents = to_gemini_history(context) + contents.append({"role": "user", "parts": [{"text": prompt}]}) + config_values: Dict[str, Any] = {} + if isinstance(llm_params, Mapping) and isinstance( + llm_params.get("temperature"), (int, float) + ): + config_values["temperature"] = llm_params["temperature"] + config_values.update( + gemini_kwargs_from_model_parameters( + llm_params, + model=model, + api_surface="gemini_generate_content", + ) + ) + if system_instruction: + config_values["system_instruction"] = system_instruction + config = genai.types.GenerateContentConfig(**config_values) # type: ignore[attr-defined] + response = self.client.models.generate_content( + model=model, + contents=contents, + config=config, + ) + content = getattr(response, "text", None) + if not isinstance(content, str) or not content.strip(): + feedback = getattr(response, "prompt_feedback", None) + block_reason = getattr(feedback, "block_reason", None) + if block_reason: + raise RuntimeError(f"Gemini response blocked due to {block_reason}.") + raise RuntimeError("Gemini returned an empty or blocked response.") + actual_model = getattr(response, "model_version", None) + transport_metadata = { + "provider": "gemini", + "requested_model": model, + "effective_model": actual_model if isinstance(actual_model, str) else model, + "effective_api_surface": "gemini_generate_content", + "connection_id": "gemini_developer_api", + "deployment_id": model, + } + self.last_response_metadata = { + "provider": "gemini", + "api_surface": "gemini_generate_content", + "requested_model": model, + "effective_model": actual_model if isinstance(actual_model, str) else model, + "usage": self._gemini_usage_mapping( + getattr(response, "usage_metadata", None) + ), + "transport_metadata": transport_metadata, + "raw_response": { + "response_id": getattr(response, "response_id", None), + "model_version": actual_model, + }, + } + return content + + @staticmethod + def _interaction_output_text(steps: Any) -> str: + text_parts: list[str] = [] + if not isinstance(steps, Sequence) or isinstance( + steps, (str, bytes, bytearray) + ): + return "" + for step in steps: + step_type = ( + step.get("type") + if isinstance(step, Mapping) + else getattr(step, "type", None) + ) + if step_type != "model_output": + continue + content = ( + step.get("content") + if isinstance(step, Mapping) + else getattr(step, "content", None) + ) + for part in content or []: + part_type = ( + part.get("type") + if isinstance(part, Mapping) + else getattr(part, "type", None) + ) + text = ( + part.get("text") + if isinstance(part, Mapping) + else getattr(part, "text", None) + ) + if part_type == "text" and isinstance(text, str): + text_parts.append(text) + return "".join(text_parts) + + @staticmethod + def _gemini_interaction_errors(value: Any) -> list[Dict[str, Any]]: + if value is None: + return [] + raw_items = ( + list(value) + if isinstance(value, Sequence) + and not isinstance(value, (str, bytes, bytearray)) + else [value] + ) + errors: list[Dict[str, Any]] = [] + for item in raw_items[:10]: + if isinstance(item, Mapping): + raw_error: Any = dict(item) + else: + dump = getattr(item, "model_dump", None) + if callable(dump): + try: + raw_error = dump(mode="json", exclude_none=True) + except TypeError: + raw_error = dump(exclude_none=True) + elif isinstance(item, str): + raw_error = {"message": item} + else: + raw_error = {"error": "provider_error_details_unavailable"} + sanitised = sanitise_transport_telemetry_value( + raw_error, + key="provider_error", + ) + if isinstance(sanitised, Mapping) and sanitised: + errors.append(dict(sanitised)) + return errors + + @staticmethod + def _gemini_usage_mapping(usage: Any) -> Dict[str, Any] | None: + if usage is None: + return None + + def read(*names: str) -> Any: + for name in names: + value = ( + usage.get(name) + if isinstance(usage, Mapping) + else getattr(usage, name, None) + ) + if isinstance(value, (int, float)): + return value + return None + + visible_output_tokens = read( + "candidates_token_count", "total_output_tokens", "visible_output_tokens" + ) + thought_tokens = read( + "thoughts_token_count", "total_thought_tokens", "thought_tokens" + ) + output_tokens = None + if isinstance(visible_output_tokens, (int, float)) or isinstance( + thought_tokens, (int, float) + ): + output_tokens = (visible_output_tokens or 0) + (thought_tokens or 0) + result = { + "input_tokens": read( + "prompt_token_count", "total_input_tokens", "input_tokens" + ), + "output_tokens": output_tokens, + "visible_output_tokens": visible_output_tokens, + "total_tokens": read("total_token_count", "total_tokens"), + "cached_input_tokens": read( + "cached_content_token_count", "total_cached_tokens", "cached_tokens" + ), + "thought_tokens": thought_tokens, + "tool_use_tokens": read("total_tool_use_tokens", "tool_use_tokens"), + } + return { + key: value for key, value in result.items() if value is not None + } or None def get_embedding(self, text: str, model: Optional[str] = None) -> List[float]: """Generate an embedding using Gemini.""" - # Ensure genai is available if genai is None: raise ImportError( "google-genai package is required for Gemini embeddings. Install with: pdm add google-genai" ) - # Cast to Any to avoid static analysis errors about exported members - genai_any = cast(Any, genai) - - target_model = model or "models/embedding-001" + target_model = model or "gemini-embedding-001" + self._assert_model_execution_allowed( + provider="gemini", + model=target_model, + ) try: - result = genai_any.embed_content( + result = self.client.models.embed_content( model=target_model, - content=text, - task_type="retrieval_document", - title="Embedding of text", + contents=text, + config=genai.types.EmbedContentConfig( # type: ignore[attr-defined] + task_type="RETRIEVAL_DOCUMENT", + title="Embedding of text", + output_dimensionality=768, + ), ) - return result["embedding"] + embeddings = getattr(result, "embeddings", None) + if not embeddings: + raise RuntimeError("Gemini returned no embedding values.") + values = getattr(embeddings[0], "values", None) + if not isinstance(values, list): + raise RuntimeError("Gemini returned an invalid embedding payload.") + return [float(value) for value in values] except Exception as e: + safe_error = sanitise_transport_telemetry_text(str(e)) logger.error( - f"Failed to generate embedding with Gemini model {target_model}: {e}" + "Failed to generate embedding with Gemini model %s: %s", + target_model, + safe_error, ) - raise RuntimeError(f"Gemini embedding error: {str(e)}") from e + raise RuntimeError(f"Gemini embedding error: {safe_error}") from e def list_models(self) -> List[str]: """List available models from Gemini.""" @@ -2409,25 +2735,26 @@ def list_models(self) -> List[str]: assert genai is not None try: - model_names = [] - try: - gm_iter = genai.list_models() # type: ignore[attr-defined] - except Exception: - return [] - for m in gm_iter: - name = getattr(m, "name", None) - methods = getattr(m, "supported_generation_methods", []) - if ( - isinstance(name, str) - and isinstance(methods, list) - and "generateContent" in methods + model_names: list[str] = [] + for item in self.client.models.list(): + name = getattr(item, "name", None) + methods = getattr(item, "supported_actions", None) + if methods is None: + methods = getattr(item, "supported_generation_methods", None) + if isinstance(name, str) and ( + not methods or "generateContent" in methods ): - model_names.append(name) - logger.debug(f"Found Gemini models: {model_names}") - return sorted(model_names) + canonical_name = ( + name.split("/", 1)[1] if name.startswith("models/") else name + ) + model_names.append(canonical_name) + result = sorted(set(model_names)) + logger.debug("Found Gemini models: %s", result) + return result except Exception as e: - logger.error(f"Error listing Gemini models: {e}", exc_info=True) - return [] + safe_error = sanitise_transport_telemetry_text(str(e)) + logger.error("Error listing Gemini models: %s", safe_error) + raise RuntimeError(f"Gemini model listing error: {safe_error}") from e def get_llm_client( @@ -2592,15 +2919,28 @@ def get_llm_client( elif provider == "gemini": try: - return GeminiClient(**kwargs) + trusted_user_concept_id, trusted_org_concept_id = ( + _resolve_effective_llm_actor_scope( + user_concept_id, + org_concept_id, + ) + ) + gemini_kwargs = dict(kwargs) + gemini_kwargs["model_execution_user_concept_id"] = ( + trusted_user_concept_id + ) + gemini_kwargs["model_execution_org_concept_id"] = ( + trusted_org_concept_id + ) + gemini_kwargs["model_execution_actor_scope_bound"] = True + return GeminiClient(**gemini_kwargs) except Exception as e: - logger.error(f"Failed to initialize Gemini client: {e}") - gemini_fb = _ensure_ollama_client() - if gemini_fb: - logger.warning("Falling back to Ollama client.") - return gemini_fb - else: - raise RuntimeError("No LLM clients available") + safe_error = sanitise_transport_telemetry_text(str(e)) + logger.error("Failed to initialise Gemini client: %s", safe_error) + raise RuntimeError( + "Gemini was selected, but its client could not be initialised. " + "No cross-provider fallback was attempted." + ) from e else: # Invalid provider should raise ValueError instead of defaulting diff --git a/src/backend/languagemodels/model_defaults.py b/src/backend/languagemodels/model_defaults.py index 34de9a0bb..566cd1f14 100644 --- a/src/backend/languagemodels/model_defaults.py +++ b/src/backend/languagemodels/model_defaults.py @@ -20,4 +20,4 @@ # --------------------------------------------------------------------------- # Gemini # --------------------------------------------------------------------------- -DEFAULT_GEMINI_MODEL: str = os.getenv("VON_DEFAULT_GEMINI_MODEL", "gemini-2.0-flash") +DEFAULT_GEMINI_MODEL: str = os.getenv("VON_DEFAULT_GEMINI_MODEL", "gemini-3.7-flash") diff --git a/src/backend/languagemodels/structured_tool_calling/providers/gemini_client.py b/src/backend/languagemodels/structured_tool_calling/providers/gemini_client.py index c5d90f3f0..9bee20407 100644 --- a/src/backend/languagemodels/structured_tool_calling/providers/gemini_client.py +++ b/src/backend/languagemodels/structured_tool_calling/providers/gemini_client.py @@ -1,289 +1,1081 @@ -"""Gemini client with structured tool calling support.""" +"""Gemini provider-native structured tool transport. + +Gemini 3.7 runs on the Interactions API with ``store=False``. Its opaque +thought/function steps are retained in :class:`LLMContinuation` and replayed +exactly on the next stateless request. Older models retain GenerateContent as +a compatibility surface, including exact model-content replay. +""" + +from __future__ import annotations import asyncio -import logging +import json +from collections.abc import Mapping, Sequence from time import monotonic -from typing import Any, Dict, List, Optional, Sequence +from typing import Any +from uuid import uuid4 -from ..types import ToolCall, ToolDefinition, LLMResponse, ToolCallError +from ....integrations.internal_mcp.tool_call_contracts import ( + strip_internal_schema_extensions, + validation_diagnostic, +) +from ....services.model_parameter_service import gemini_kwargs_from_model_parameters from ..client import ( LLMClient, LLMClientConfig, observe_request_advisory, split_request_advisory_from_llm_params, ) -from ....integrations.internal_mcp.tool_call_contracts import validation_diagnostic +from ..transport import ( + sanitise_transport_telemetry_text, + sanitise_transport_telemetry_value, +) +from ..types import ( + LLMContinuation, + LLMResponse, + StructuredToolProtocolError, + StructuredToolTransportError, + ToolCall, + ToolCallError, + ToolDefinition, + ToolResult, +) -logger = logging.getLogger(__name__) +GEMINI_INTERACTIONS_SURFACE = "interactions" +GEMINI_GENERATE_CONTENT_SURFACE = "gemini_generate_content" -class GeminiClient(LLMClient): - """LLM client for Google Gemini models with function calling support. +def _value(value: Any, key: str, default: Any = None) -> Any: + if isinstance(value, Mapping): + return value.get(key, default) + return getattr(value, key, default) + + +def _provider_mapping(value: Any) -> dict[str, Any]: + """Return provider data in JSON form, without SDK HTTP response internals.""" + + if isinstance(value, Mapping): + return dict(value) + dumper = getattr(value, "model_dump", None) + if not callable(dumper): + return {} + try: + result = dumper(mode="json", exclude_none=True) + except TypeError: # google-genai 1.x compatibility + result = dumper(exclude_none=True) + return dict(result) if isinstance(result, Mapping) else {} + - Supports native function calling for Gemini Pro and Gemini Pro Vision. - Falls back to JSON-in-text parsing if structured calling fails and fallback is enabled. - """ +def _provider_error_evidence(value: Any) -> list[dict[str, Any]]: + """Return bounded, credential-free provider errors without SDK internals.""" + + if value is None: + return [] + raw_items = ( + list(value) + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)) + else [value] + ) + evidence: list[dict[str, Any]] = [] + for item in raw_items[:10]: + mapping = _provider_mapping(item) + if mapping: + sanitised = sanitise_transport_telemetry_value( + mapping, + key="provider_error", + ) + if isinstance(sanitised, Mapping): + evidence.append(dict(sanitised)) + continue + if isinstance(item, str) and item.strip(): + evidence.append( + {"message": sanitise_transport_telemetry_text(item.strip())[:512]} + ) + else: + evidence.append({"error": "provider_error_details_unavailable"}) + return evidence + + +def _json_text(value: Any) -> str: + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=True, default=str, separators=(",", ":")) + + +def _is_gemini_37_flash(model: str) -> bool: + model_id = str(model or "").strip().lower() + if model_id.startswith("models/"): + model_id = model_id.split("/", 1)[1] + return model_id == "gemini-3.7-flash" or model_id.startswith("gemini-3.7-flash-") + + +class GeminiClient(LLMClient): + """Gemini client with exact provider tool-call correlation.""" def __init__(self, config: LLMClientConfig): super().__init__(config) - try: from google import genai - except ImportError: - raise ImportError("google-genai package required for GeminiClient") + except ImportError as exc: # pragma: no cover + raise ImportError("google-genai package required for GeminiClient") from exc self._genai = genai - - # Create client with API key if provided - client_kwargs: dict[str, Any] = {} + self._client_kwargs: dict[str, Any] = {} if config.api_key: - client_kwargs["api_key"] = config.api_key - - self._client_kwargs = client_kwargs - self._client = genai.Client(**client_kwargs) # type: ignore[attr-defined] + self._client_kwargs["api_key"] = config.api_key + self._client = genai.Client(**self._client_kwargs) self._model_name = config.model self._temperature = config.temperature self._max_tokens = config.max_tokens or 2048 + self._assert_interactions_available(self._client, self._model_name) + + @staticmethod + def _assert_interactions_available(client: Any, model: str) -> None: + if ( + _is_gemini_37_flash(model) + and getattr(getattr(client, "aio", None), "interactions", None) is None + ): + raise ImportError( + "Gemini 3.7 structured tools require a google-genai release " + "whose Client exposes client.aio.interactions." + ) async def generate_with_tools( self, prompt: str, - available_tools: List[ToolDefinition], - context: Optional[Sequence[Dict[str, Any]]] = None, - system_message: Optional[str] = None, + available_tools: list[ToolDefinition], + context: Sequence[dict[str, Any]] | None = None, + system_message: str | None = None, **kwargs: Any, ) -> LLMResponse: - """Generate response using Gemini's native asynchronous client.""" - for tool in available_tools: self._validate_input_schema(tool) request_kwargs = dict(kwargs) - _, request_advisory_seconds = split_request_advisory_from_llm_params( + request_client = request_kwargs.pop("_request_client", self._client) + request_model = str(request_kwargs.pop("model", None) or self._model_name) + llm_params, advisory_seconds = split_request_advisory_from_llm_params( request_kwargs.pop("llm_params", None) ) - request_started_monotonic = monotonic() - request_client = self._client - - try: - tools = self._convert_tools_to_gemini_format(available_tools) - messages = self._build_messages(prompt, context, system_message) - config = self._genai.types.GenerateContentConfig( - temperature=self._temperature, - max_output_tokens=self._max_tokens, - tools=tools, + raw_continuation = request_kwargs.pop("continuation", None) + continuation = LLMContinuation.from_value(raw_continuation) + if raw_continuation is not None and continuation is None: + raise StructuredToolProtocolError( + "Structured continuation payload is malformed." ) - - response = await request_client.aio.models.generate_content( - model=self._model_name, - contents=messages, # type: ignore[arg-type] - config=config, + tool_results = self._coerce_tool_results( + request_kwargs.pop("tool_results", None) + ) + if tool_results and continuation is None: + raise StructuredToolProtocolError( + "Structured tool results require a valid continuation payload." ) + surface = ( + GEMINI_INTERACTIONS_SURFACE + if _is_gemini_37_flash(request_model) + else GEMINI_GENERATE_CONTENT_SURFACE + ) + if continuation is not None: + self._validate_continuation( + continuation, + model=request_model, + surface=surface, + tool_results=tool_results, + ) + + started = monotonic() + try: + if surface == GEMINI_INTERACTIONS_SURFACE: + self._assert_interactions_available(request_client, request_model) + response = await self._generate_interaction( + prompt=prompt, + available_tools=available_tools, + context=context, + system_message=system_message, + request_model=request_model, + llm_params=llm_params, + continuation=continuation, + tool_results=tool_results, + request_client=request_client, + ) + else: + response = await self._generate_content( + prompt=prompt, + available_tools=available_tools, + context=context, + system_message=system_message, + request_model=request_model, + llm_params=llm_params, + continuation=continuation, + tool_results=tool_results, + request_client=request_client, + ) observe_request_advisory( provider="gemini", - advisory_seconds=request_advisory_seconds, - started_monotonic=request_started_monotonic, + advisory_seconds=advisory_seconds, + started_monotonic=started, event_logger=self.logger, ) - return self._parse_response(response, available_tools) + return response + except StructuredToolTransportError: + raise except Exception as exc: - self.logger.error("Gemini API error: %s", exc) - if self.config.fallback_to_json_text: - self.logger.info("Falling back to JSON-in-text parsing") - raise ToolCallError(f"Gemini call failed: {exc}") from exc + safe_error = sanitise_transport_telemetry_text(str(exc)) + self.logger.error("Gemini structured-tool API error: %s", safe_error) + raise ToolCallError(f"Gemini call failed: {safe_error}") from exc def generate_with_tools_sync( self, prompt: str, - available_tools: List[ToolDefinition], - context: Optional[Sequence[Dict[str, Any]]] = None, - system_message: Optional[str] = None, + available_tools: list[ToolDefinition], + context: Sequence[dict[str, Any]] | None = None, + system_message: str | None = None, **kwargs: Any, ) -> LLMResponse: - """Synchronous wrapper around the same bounded transport.""" + """Use a fresh SDK transport for the isolated synchronous event loop.""" - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - return loop.run_until_complete( - self.generate_with_tools( + request_client = self._genai.Client(**self._client_kwargs) + + async def _run() -> LLMResponse: + try: + return await self.generate_with_tools( prompt, available_tools, context, system_message, + _request_client=request_client, **kwargs, ) - ) + finally: + close = getattr(getattr(request_client, "aio", None), "aclose", None) + if callable(close): + await close() + + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(_run()) finally: loop.close() - def _convert_tools_to_gemini_format( + async def _generate_interaction( self, - tools: List[ToolDefinition], - ) -> Any: - """Convert ToolDefinitions to Gemini tool format.""" - gemini_tools = [] - - for tool in tools: - # Gemini expects function definitions - gemini_tool = self._genai.types.Tool( # type: ignore[attr-defined] - function_declarations=[ - self._genai.types.FunctionDeclaration( # type: ignore[attr-defined] - name=tool.name, - description=tool.description, - parameters=self._genai.types.Schema( # type: ignore[attr-defined] - type=self._genai.types.Type.OBJECT, # type: ignore[attr-defined] - properties={ - prop: self._schema_property_to_gemini(prop_schema) - for prop, prop_schema in tool.input_schema.get( - "properties", {} - ).items() - }, - required=tool.input_schema.get("required", []), - ), - ) - ] - ) - gemini_tools.append(gemini_tool) - - return gemini_tools - - def _schema_property_to_gemini(self, prop_schema: Dict[str, Any]) -> Any: - """Convert JSON Schema property to Gemini schema.""" - prop_type = prop_schema.get("type", "string") - - # Map JSON Schema types to Gemini types - type_map = { - "string": self._genai.types.Type.STRING, # type: ignore[attr-defined] - "number": self._genai.types.Type.NUMBER, # type: ignore[attr-defined] - "integer": self._genai.types.Type.INTEGER, # type: ignore[attr-defined] - "boolean": self._genai.types.Type.BOOLEAN, # type: ignore[attr-defined] - "array": self._genai.types.Type.ARRAY, # type: ignore[attr-defined] - "object": self._genai.types.Type.OBJECT, # type: ignore[attr-defined] + *, + prompt: str, + available_tools: list[ToolDefinition], + context: Sequence[dict[str, Any]] | None, + system_message: str | None, + request_model: str, + llm_params: Mapping[str, Any], + continuation: LLMContinuation | None, + tool_results: list[ToolResult], + request_client: Any, + ) -> LLMResponse: + if continuation is None: + input_steps, context_system = self._interaction_input(prompt, context) + else: + context_system = [] + input_steps = [ + *[dict(item) for item in continuation.input_items], + *[dict(item) for item in continuation.output_items], + *self._interaction_function_results(continuation, tool_results), + ] + if prompt.strip(): + input_steps.append(self._text_step("user_input", prompt)) + + projected = gemini_kwargs_from_model_parameters( + llm_params, + model=request_model, + api_surface=GEMINI_INTERACTIONS_SURFACE, + ) + generation_config: dict[str, Any] = {"max_output_tokens": self._max_tokens} + projection = projected.get("generation_config") + if isinstance(projection, Mapping): + generation_config.update(dict(projection)) + instruction_parts = list(context_system) + if isinstance(system_message, str) and system_message.strip(): + instruction_parts.append(system_message.strip()) + request: dict[str, Any] = { + "model": request_model, + "input": input_steps, + "store": False, + "generation_config": generation_config, } + tools = [self._interaction_tool(tool) for tool in available_tools] + if tools: + request["tools"] = tools + if instruction_parts: + request["system_instruction"] = "\n\n".join(instruction_parts) + interaction = await request_client.aio.interactions.create(**request) + return self._parse_interaction( + interaction, + available_tools, + model=request_model, + input_steps=input_steps, + effective_parameters=projected, + ) - gemini_type = type_map.get(prop_type, self._genai.types.Type.STRING) # type: ignore[attr-defined] + async def _generate_content( + self, + *, + prompt: str, + available_tools: list[ToolDefinition], + context: Sequence[dict[str, Any]] | None, + system_message: str | None, + request_model: str, + llm_params: Mapping[str, Any], + continuation: LLMContinuation | None, + tool_results: list[ToolResult], + request_client: Any, + ) -> LLMResponse: + if continuation is None: + contents, context_system = self._content_input(prompt, context) + input_items = [self._dump_content(item) for item in contents] + else: + context_system = [] + input_items = [ + *[dict(item) for item in continuation.input_items], + *[dict(item) for item in continuation.output_items], + ] + contents = [ + self._genai.types.Content.model_validate(item) for item in input_items + ] + contents.extend(self._content_function_results(continuation, tool_results)) + if prompt.strip(): + contents.append(self._content("user", prompt)) - return self._genai.types.Schema( # type: ignore[attr-defined] - type=gemini_type, - description=prop_schema.get("description", ""), + projected = gemini_kwargs_from_model_parameters( + llm_params, + model=request_model, + api_surface=GEMINI_GENERATE_CONTENT_SURFACE, + ) + config_values: dict[str, Any] = { + "max_output_tokens": self._max_tokens, + "tools": self._generate_content_tools(available_tools), + **projected, + } + if self._temperature is not None: + config_values["temperature"] = self._temperature + instruction_parts = list(context_system) + if isinstance(system_message, str) and system_message.strip(): + instruction_parts.append(system_message.strip()) + if instruction_parts: + config_values["system_instruction"] = "\n\n".join(instruction_parts) + response = await request_client.aio.models.generate_content( + model=request_model, + contents=contents, + config=self._genai.types.GenerateContentConfig(**config_values), + ) + return self._parse_generate_content( + response, + available_tools, + model=request_model, + input_items=input_items, + effective_parameters=projected, ) - def _build_messages( + @staticmethod + def _text_step(step_type: str, text: str) -> dict[str, Any]: + return { + "type": step_type, + "content": [{"type": "text", "text": text}], + } + + def _interaction_input( self, prompt: str, - context: Optional[Sequence[Dict[str, Any]]], - system_message: Optional[str], - ) -> List[Dict[str, str]]: - """Build Gemini message list.""" - messages: List[Dict[str, str]] = [] - - if system_message: - messages.append({"role": "user", "content": system_message}) - - if context: - for msg in context: - if isinstance(msg, dict) and "role" in msg and "content" in msg: - # Normalise role for Gemini (uses 'user' and 'model') - role = "model" if msg["role"] == "assistant" else msg["role"] - messages.append( - { - "role": role, - "content": msg["content"], - } + context: Sequence[dict[str, Any]] | None, + ) -> tuple[list[dict[str, Any]], list[str]]: + steps: list[dict[str, Any]] = [] + system_parts: list[str] = [] + for message in context or []: + if not isinstance(message, Mapping): + continue + role = str(message.get("role") or "user").strip().lower() + content = _json_text(message.get("content", "")) + if role == "system": + if content.strip(): + system_parts.append(content) + elif role in {"assistant", "model"}: + steps.append(self._text_step("model_output", content)) + elif role == "tool": + steps.append( + self._text_step( + "user_input", + "Prior tool evidence (not provider continuation): " + content, + ) + ) + else: + steps.append(self._text_step("user_input", content)) + steps.append(self._text_step("user_input", prompt)) + return steps, system_parts + + def _content_input( + self, + prompt: str, + context: Sequence[dict[str, Any]] | None, + ) -> tuple[list[Any], list[str]]: + contents: list[Any] = [] + system_parts: list[str] = [] + for message in context or []: + if not isinstance(message, Mapping): + continue + role = str(message.get("role") or "user").strip().lower() + content = _json_text(message.get("content", "")) + if role == "system": + if content.strip(): + system_parts.append(content) + elif role in {"assistant", "model"}: + contents.append(self._content("model", content)) + elif role == "tool": + contents.append( + self._content( + "user", + "Prior tool evidence (not provider continuation): " + content, ) + ) + else: + contents.append(self._content("user", content)) + contents.append(self._content("user", prompt)) + return contents, system_parts - messages.append({"role": "user", "content": prompt}) + def _content(self, role: str, text: str) -> Any: + return {"role": role, "parts": [{"text": text}]} - return messages + @staticmethod + def _interaction_tool(tool: ToolDefinition) -> dict[str, Any]: + return { + "type": "function", + "name": tool.name, + "description": tool.description, + "parameters": strip_internal_schema_extensions(tool.input_schema), + } - def _parse_response( + def _generate_content_tools(self, tools: list[ToolDefinition]) -> list[Any]: + return [ + { + "function_declarations": [ + { + "name": tool.name, + "description": tool.description, + "parameters_json_schema": strip_internal_schema_extensions( + tool.input_schema + ), + } + ] + } + for tool in tools + ] + + @staticmethod + def _dump_content(content: Any) -> dict[str, Any]: + return _provider_mapping(content) + + def _parse_interaction( self, - response: Any, - available_tools: List[ToolDefinition], + interaction: Any, + available_tools: list[ToolDefinition], + *, + model: str, + input_steps: list[dict[str, Any]], + effective_parameters: Mapping[str, Any], ) -> LLMResponse: - """Parse Gemini response into canonical LLMResponse format.""" + status_value = _value(interaction, "status") + status_text = getattr(status_value, "value", status_value) + status = ( + str(status_text).strip().lower() + if status_text is not None and str(status_text).strip() + else None + ) + provider_errors = _provider_error_evidence(_value(interaction, "errors")) + if (status is not None and status != "completed") or provider_errors: + raise StructuredToolTransportError( + "Gemini Interactions returned an unsuccessful provider status.", + decision={ + "provider": "gemini", + "effective_api_surface": GEMINI_INTERACTIONS_SURFACE, + "model": model, + "provider_status": status, + "provider_errors": provider_errors, + "failure_kind": "provider_response_failed", + }, + ) + output_steps = [ + _provider_mapping(step) for step in (_value(interaction, "steps") or []) + ] + output_steps = [step for step in output_steps if step] + text = _value(interaction, "output_text") + if not isinstance(text, str): + text = self._text_from_interaction_steps(output_steps) + tool_calls, diagnostics, call_names = self._normalise_function_steps( + output_steps, + available_tools, + surface=GEMINI_INTERACTIONS_SURFACE, + ) + actual_model = _value(interaction, "model") + return self._response( + text=text or "", + tool_calls=tool_calls, + diagnostics=diagnostics, + provider_call_names=call_names, + output_items=output_steps, + input_items=input_steps, + requested_model=model, + actual_model=actual_model, + surface=GEMINI_INTERACTIONS_SURFACE, + usage=_value(interaction, "usage"), + raw_response={ + "id": _value(interaction, "id"), + "model": actual_model, + "status": _value(interaction, "status"), + "steps": output_steps, + "usage": _provider_mapping(_value(interaction, "usage")), + "errors": provider_errors, + }, + effective_parameters=effective_parameters, + ) - text_response = "" - tool_calls: List[ToolCall] = [] - tool_call_diagnostics: List[Dict[str, Any]] = [] + def _parse_generate_content( + self, + response: Any, + available_tools: list[ToolDefinition], + *, + model: str, + input_items: list[dict[str, Any]], + effective_parameters: Mapping[str, Any], + ) -> LLMResponse: + candidates = _value(response, "candidates") or [] + content = _value(candidates[0], "content") if candidates else None + output_items = [self._dump_content(content)] if content is not None else [] + function_steps: list[dict[str, Any]] = [] + text_parts: list[str] = [] + for part in _value(content, "parts") or []: + part_text = _value(part, "text") + if isinstance(part_text, str) and not bool(_value(part, "thought")): + text_parts.append(part_text) + function_call = _value(part, "function_call") + if function_call is not None: + function_steps.append( + { + "type": "function_call", + "id": _value(function_call, "id"), + "name": _value(function_call, "name"), + "arguments": _value(function_call, "args"), + } + ) + if not function_steps: + provider_calls = _value(response, "function_calls") or [] + for function_call in provider_calls: + function_steps.append( + { + "type": "function_call", + "id": _value(function_call, "id"), + "name": _value(function_call, "name"), + "arguments": _value(function_call, "args"), + } + ) + tool_calls, diagnostics, call_names = self._normalise_function_steps( + function_steps, + available_tools, + surface=GEMINI_GENERATE_CONTENT_SURFACE, + ) + actual_model = _value(response, "model_version") or model + raw_candidates: list[dict[str, Any]] = [] + for candidate in candidates[:1]: + raw_candidates.append( + { + "content": self._dump_content(_value(candidate, "content")), + "finish_reason": str(_value(candidate, "finish_reason") or ""), + } + ) + self._apply_generate_content_call_ids(output_items, function_steps) + return self._response( + text=( + "".join(text_parts) + or ( + _value(response, "text") + if isinstance(_value(response, "text"), str) + else "" + ) + ), + tool_calls=tool_calls, + diagnostics=diagnostics, + provider_call_names=call_names, + output_items=output_items, + input_items=input_items, + requested_model=model, + actual_model=actual_model, + surface=GEMINI_GENERATE_CONTENT_SURFACE, + usage=_value(response, "usage_metadata"), + raw_response={ + "response_id": _value(response, "response_id"), + "model_version": actual_model, + "candidates": raw_candidates, + "usage_metadata": _provider_mapping(_value(response, "usage_metadata")), + }, + effective_parameters=effective_parameters, + ) - tool_name_map = {tool.name: tool for tool in available_tools} + def _normalise_function_steps( + self, + steps: Sequence[Mapping[str, Any]], + available_tools: list[ToolDefinition], + *, + surface: str, + ) -> tuple[list[ToolCall], list[dict[str, Any]], dict[str, str]]: + known = {tool.name: tool for tool in available_tools} + calls: list[ToolCall] = [] + diagnostics: list[dict[str, Any]] = [] + call_names: dict[str, str] = {} + seen: set[str] = set() + for step in steps: + if step.get("type") != "function_call": + continue + call_id = str(step.get("id") or "").strip() + name = str(step.get("name") or "").strip() + provider_item_id = call_id or None + if not name or ( + not call_id and surface == GEMINI_INTERACTIONS_SURFACE + ): + raise StructuredToolProtocolError( + "Gemini returned a function call without the required id and name.", + decision={ + "provider": "gemini", + "effective_api_surface": surface, + "failure_kind": "missing_provider_call_correlation", + }, + ) + if not call_id: + call_id = f"von-gemini-{uuid4()}" + if isinstance(step, dict): + step["id"] = call_id + if call_id in seen: + raise StructuredToolProtocolError( + "Gemini returned duplicate provider function-call IDs." + ) + seen.add(call_id) + call_names[call_id] = name + arguments = step.get("arguments") + if not isinstance(arguments, Mapping): + diagnostics.append( + validation_diagnostic( + tool=name, + error_code="arguments_not_object", + message=f"Tool '{name}' arguments must be a JSON object.", + payload={"call_id": call_id}, + ) + ) + continue + if name not in known: + diagnostics.append( + validation_diagnostic( + tool=name, + error_code="unknown_tool", + message=f"Unknown tool requested: {name}", + payload={"call_id": call_id}, + ) + ) + continue + calls.append( + ToolCall( + tool_name=name, + payload=dict(arguments), + call_id=call_id, + provider_item_id=provider_item_id, + ) + ) + if diagnostics and not calls: + raise StructuredToolProtocolError( + "Gemini returned function calls, but none satisfied the advertised " + "tool contract.", + decision={ + "provider": "gemini", + "effective_api_surface": surface, + "failure_kind": "provider_tool_call_rejected", + }, + ) + return calls, diagnostics, call_names - try: - # Extract text and function calls from response - if hasattr(response, "text"): - text_response = response.text - - if hasattr(response, "function_calls") and response.function_calls: - for fc in response.function_calls(): - try: - tool_name = fc.name - - # Validate tool exists - if tool_name not in tool_name_map: - message = f"Unknown tool requested: {tool_name}" - self.logger.warning(message) - tool_call_diagnostics.append( - validation_diagnostic( - tool=tool_name, - error_code="unknown_tool", - message=message, - ) - ) - continue - - # Get arguments dict - payload = dict(fc.args) if hasattr(fc, "args") else {} - if not isinstance(payload, dict): - message = ( - f"Tool '{tool_name}' arguments must be a JSON object." - ) - self.logger.error(message) - tool_call_diagnostics.append( - validation_diagnostic( - tool=tool_name, - error_code="arguments_not_object", - message=message, - ) - ) - continue - - tool_calls.append( - ToolCall( - tool_name=tool_name, - payload=payload, - ) - ) + @staticmethod + def _apply_generate_content_call_ids( + output_items: list[dict[str, Any]], + function_steps: Sequence[Mapping[str, Any]], + ) -> None: + """Add legacy internal IDs to the exact content retained for replay.""" - except Exception as exc: - message = f"Failed to parse Gemini function call: {exc}" - self.logger.error(message) - tool_call_diagnostics.append( - validation_diagnostic( - tool=None, - error_code="provider_tool_call_parse_error", - message=message, - ) - ) - continue + call_ids = [str(step.get("id") or "").strip() for step in function_steps] + call_index = 0 + for content in output_items: + parts = content.get("parts") + if not isinstance(parts, list): + continue + for part in parts: + if call_index >= len(call_ids) or not isinstance(part, dict): + continue + function_call = part.get("function_call") + if not isinstance(function_call, dict): + function_call = part.get("functionCall") + if not isinstance(function_call, dict): + continue + call_id = call_ids[call_index] + call_index += 1 + if not str(function_call.get("id") or "").strip() and call_id: + function_call["id"] = call_id - except Exception as exc: - message = f"Error parsing Gemini response: {exc}" - self.logger.error(message) - tool_call_diagnostics.append( - validation_diagnostic( - tool=None, - error_code="provider_response_parse_error", - message=message, + def _response( + self, + *, + text: str, + tool_calls: list[ToolCall], + diagnostics: list[dict[str, Any]], + provider_call_names: Mapping[str, str], + output_items: list[dict[str, Any]], + input_items: list[dict[str, Any]], + requested_model: str, + actual_model: Any, + surface: str, + usage: Any, + raw_response: dict[str, Any], + effective_parameters: Mapping[str, Any], + ) -> LLMResponse: + accepted_ids = [call.call_id for call in tool_calls] + rejected: list[dict[str, str]] = [] + for diagnostic in diagnostics: + payload = diagnostic.get("payload") + call_id = payload.get("call_id") if isinstance(payload, Mapping) else None + if isinstance(call_id, str) and call_id.strip(): + rejected.append( + { + "call_id": call_id, + "name": provider_call_names[call_id], + "error_code": str( + diagnostic.get("error_code") or "invalid_provider_tool_call" + ), + } ) + effective_model = ( + str(actual_model) if isinstance(actual_model, str) else requested_model + ) + transport = { + "provider": "gemini", + "requested_model": requested_model, + "effective_model": effective_model, + "effective_api_surface": surface, + "connection_id": self.config.connection_id or "gemini_developer_api", + "deployment_id": self.config.deployment_id or requested_model, + "continuation_mode": "stateless", + "store": False, + "effective_model_parameters": dict(effective_parameters), + "provider_tool_call_diagnostic_codes": [ + str(item.get("error_code") or "invalid_provider_tool_call") + for item in diagnostics + ], + } + continuation = None + if tool_calls: + continuation = LLMContinuation( + provider="gemini", + api_surface=surface, + model=requested_model, + state_mode="stateless", + connection_id=transport["connection_id"], + deployment_id=transport["deployment_id"], + input_items=[dict(item) for item in input_items], + output_items=[dict(item) for item in output_items], + transport_decision={ + **transport, + "accepted_tool_call_ids": accepted_ids, + "provider_call_names": dict(provider_call_names), + "rejected_tool_calls": rejected, + }, ) - return LLMResponse( - text_response=text_response, + text_response=text, tool_calls=tool_calls, - raw_response=None, # Gemini response object is not easily serialisable - model=self.config.model, - usage=None, # Gemini doesn't expose token usage easily - tool_call_diagnostics=tool_call_diagnostics, + raw_response=raw_response, + model=effective_model, + usage=self._usage_mapping(usage), + tool_call_diagnostics=diagnostics, + continuation=continuation, + transport_metadata=transport, ) + + @staticmethod + def _text_from_interaction_steps(steps: Sequence[Mapping[str, Any]]) -> str: + text_parts: list[str] = [] + for step in steps: + if step.get("type") != "model_output": + continue + content = step.get("content") + if not isinstance(content, Sequence) or isinstance( + content, (str, bytes, bytearray) + ): + continue + for part in content: + if isinstance(part, Mapping) and part.get("type") == "text": + text = part.get("text") + if isinstance(text, str): + text_parts.append(text) + return "".join(text_parts) + + @staticmethod + def _usage_mapping(usage: Any) -> dict[str, Any] | None: + raw = _provider_mapping(usage) + if not raw: + return None + + def first(*keys: str) -> Any: + for key in keys: + value = raw.get(key) + if isinstance(value, (int, float)): + return value + return None + + visible_output_tokens = first( + "candidates_token_count", "total_output_tokens", "visible_output_tokens" + ) + thought_tokens = first( + "thoughts_token_count", "total_thought_tokens", "thought_tokens" + ) + output_tokens = None + if isinstance(visible_output_tokens, (int, float)) or isinstance( + thought_tokens, (int, float) + ): + output_tokens = (visible_output_tokens or 0) + (thought_tokens or 0) + mapped = { + "input_tokens": first( + "prompt_token_count", "total_input_tokens", "input_tokens" + ), + "output_tokens": output_tokens, + "visible_output_tokens": visible_output_tokens, + "total_tokens": first("total_token_count", "total_tokens"), + "cached_input_tokens": first( + "cached_content_token_count", + "total_cached_tokens", + "cached_tokens", + ), + "thought_tokens": thought_tokens, + "tool_use_tokens": first("total_tool_use_tokens", "tool_use_tokens"), + } + if ( + mapped["total_tokens"] is None + and isinstance(mapped["input_tokens"], (int, float)) + and isinstance(mapped["output_tokens"], (int, float)) + ): + mapped["total_tokens"] = mapped["input_tokens"] + mapped["output_tokens"] + return {key: value for key, value in mapped.items() if value is not None} + + @staticmethod + def _coerce_tool_results(raw: Any) -> list[ToolResult]: + if raw is None: + return [] + if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes, bytearray)): + raise StructuredToolProtocolError("tool_results must be a sequence") + results: list[ToolResult] = [] + for item in raw: + if isinstance(item, ToolResult): + results.append(item) + elif isinstance(item, Mapping): + results.append( + ToolResult( + call_id=str(item.get("call_id") or ""), + tool_name=( + str(item.get("tool_name")) + if isinstance(item.get("tool_name"), str) + else None + ), + output=item.get("output"), + status=str(item.get("status") or "ok"), + ) + ) + else: + raise StructuredToolProtocolError("tool result must be a mapping") + return results + + def _validate_continuation( + self, + continuation: LLMContinuation, + *, + model: str, + surface: str, + tool_results: list[ToolResult], + ) -> None: + expected_connection = self.config.connection_id or "gemini_developer_api" + expected_deployment = self.config.deployment_id or model + if ( + continuation.provider != "gemini" + or continuation.api_surface != surface + or continuation.model != model + or continuation.state_mode != "stateless" + or continuation.connection_id != expected_connection + or continuation.deployment_id != expected_deployment + ): + raise StructuredToolProtocolError( + "Gemini structured continuation does not match the selected " + "provider, surface, model, or deployment." + ) + accepted = continuation.transport_decision.get("accepted_tool_call_ids") + names = continuation.transport_decision.get("provider_call_names") + if not isinstance(accepted, list) or not isinstance(names, Mapping): + raise StructuredToolProtocolError( + "Gemini structured continuation is missing provider call lineage." + ) + expected_ids = [ + call_id + for call_id in accepted + if isinstance(call_id, str) and call_id.strip() + ] + actual_ids = [result.call_id for result in tool_results] + represented_calls = self._continuation_output_calls(continuation) + rejected = continuation.transport_decision.get("rejected_tool_calls", []) + rejected_ids = [ + item.get("call_id") + for item in rejected + if isinstance(item, Mapping) + and isinstance(item.get("call_id"), str) + and item.get("call_id") + ] + lineage_ids = [*expected_ids, *rejected_ids] + if ( + not expected_ids + or len(expected_ids) != len(set(expected_ids)) + or len(actual_ids) != len(set(actual_ids)) + or set(expected_ids) != set(actual_ids) + or len(lineage_ids) != len(set(lineage_ids)) + or set(represented_calls) != set(lineage_ids) + ): + raise StructuredToolProtocolError( + "Gemini continuation tool results do not match provider call IDs." + ) + for result in tool_results: + expected_name = names.get(result.call_id) + if not isinstance(expected_name, str) or not expected_name.strip(): + raise StructuredToolProtocolError( + "Gemini continuation is missing a correlated function name." + ) + if result.tool_name is not None and result.tool_name != expected_name: + raise StructuredToolProtocolError( + "Gemini continuation tool result name does not match its call ID." + ) + for call_id, name in represented_calls.items(): + if names.get(call_id) != name: + raise StructuredToolProtocolError( + "Gemini continuation function names do not match replayed calls." + ) + + @staticmethod + def _continuation_output_calls( + continuation: LLMContinuation, + ) -> dict[str, str]: + calls: dict[str, str] = {} + if continuation.api_surface == GEMINI_INTERACTIONS_SURFACE: + candidate_steps: Sequence[Mapping[str, Any]] = continuation.output_items + else: + flattened: list[Mapping[str, Any]] = [] + for content in continuation.output_items: + parts = content.get("parts") + if not isinstance(parts, Sequence) or isinstance( + parts, (str, bytes, bytearray) + ): + continue + for part in parts: + if not isinstance(part, Mapping): + continue + function_call = part.get("function_call") + if not isinstance(function_call, Mapping): + function_call = part.get("functionCall") + if isinstance(function_call, Mapping): + flattened.append( + { + "type": "function_call", + "id": function_call.get("id"), + "name": function_call.get("name"), + } + ) + candidate_steps = flattened + for step in candidate_steps: + if step.get("type") != "function_call": + continue + call_id = step.get("id") + name = step.get("name") + if not isinstance(call_id, str) or not call_id.strip(): + raise StructuredToolProtocolError( + "Gemini continuation contains an uncorrelated function call." + ) + if not isinstance(name, str) or not name.strip() or call_id in calls: + raise StructuredToolProtocolError( + "Gemini continuation contains invalid function-call lineage." + ) + calls[call_id] = name + return calls + + def _interaction_function_results( + self, + continuation: LLMContinuation, + results: list[ToolResult], + ) -> list[dict[str, Any]]: + names = continuation.transport_decision["provider_call_names"] + items = [ + { + "type": "function_result", + "name": names[result.call_id], + "call_id": result.call_id, + "result": [{"type": "text", "text": _json_text(result.output)}], + **({"is_error": True} if result.status.lower() != "ok" else {}), + } + for result in results + ] + for rejected in continuation.transport_decision.get("rejected_tool_calls", []): + if not isinstance(rejected, Mapping): + continue + items.append( + { + "type": "function_result", + "name": rejected["name"], + "call_id": rejected["call_id"], + "result": [ + { + "type": "text", + "text": _json_text( + { + "status": "not_executed", + "error_code": rejected["error_code"], + } + ), + } + ], + "is_error": True, + } + ) + return items + + def _content_function_results( + self, + continuation: LLMContinuation, + results: list[ToolResult], + ) -> list[Any]: + names = continuation.transport_decision["provider_call_names"] + parts = [] + for result in results: + response_key = "output" if result.status.lower() == "ok" else "error" + parts.append( + self._genai.types.Part( + function_response=self._genai.types.FunctionResponse( + id=result.call_id, + name=names[result.call_id], + response={response_key: result.output}, + ) + ) + ) + for rejected in continuation.transport_decision.get("rejected_tool_calls", []): + if not isinstance(rejected, Mapping): + continue + parts.append( + self._genai.types.Part( + function_response=self._genai.types.FunctionResponse( + id=rejected["call_id"], + name=rejected["name"], + response={"error": rejected["error_code"]}, + ) + ) + ) + return [self._genai.types.Content(role="user", parts=parts)] if parts else [] diff --git a/src/backend/server/routes/settings_routes.py b/src/backend/server/routes/settings_routes.py index 8111a287f..6f54ecebe 100644 --- a/src/backend/server/routes/settings_routes.py +++ b/src/backend/server/routes/settings_routes.py @@ -101,12 +101,18 @@ from ...services.concept_service import list_concepts, get_concept_by_id from ...services.concept_service import ConceptNotFoundError from ...languagemodels.llm_interface import ( + GeminiClient, ModelExecutionEligibilityError, OpenAIClient, assert_model_execution_allowed, get_ollama_auto_pull_state_snapshot, resolve_openai_responses_max_output_tokens, ) +from ...utils.runtime_env import ( + load_secret_from_env_or_file, + read_repo_dotenv_values, + read_secret_file, +) from ...db.repositories.concepts_repository import ConceptsRepository from bson import ObjectId from pymongo.errors import BulkWriteError @@ -796,10 +802,15 @@ def get_llm_info(): # Allow the client to pass the effective provider when a local model preference # overrides the DB-stored setting (e.g. premium disabled, Ollama selected locally). - # Only 'openai' and 'ollama' are accepted; any other value is ignored. + # Only known providers are accepted; any other value is ignored. effective_provider_override = request.args.get("effective_provider") - if effective_provider_override in ("openai", "ollama"): + if effective_provider_override in ("openai", "gemini", "ollama"): provider = effective_provider_override + effective_model_override = str( + request.args.get("effective_model") or "" + ).strip() + if effective_model_override: + model = effective_model_override status = "unknown" error_message = None @@ -835,6 +846,31 @@ def get_llm_info(): status = "error" error_message = str(e) + elif provider == "gemini": + api_key = _resolve_settings_api_key( + "GEMINI_API_KEY", + fallback_env_vars=("GOOGLE_API_KEY",), + ) + if not api_key: + status = "missing_key" + error_message = "Gemini API key not set" + else: + try: + client = GeminiClient( + api_key=api_key, + **({"default_model": model} if model else {}), + ) + client.list_models() + status = "ready" + ping_ok = True + except Exception as e: + status = "error" + error_message = "Gemini status check failed." + current_app.logger.warning( + "Gemini LLM status check failed (error_type=%s)", + type(e).__name__, + ) + elif provider == "ollama": host = get_active_ollama_host() details["host"] = host @@ -1002,6 +1038,7 @@ def get_all_settings_data(): # Merge DB settings with computed/env-var settings return { **db_settings, # active_llm, openai_api_key_env_var, fetch_counts_on_load, etc. + "gemini_api_key_env_var": "GEMINI_API_KEY", "buttonify_prompt_ids": list(BUTTONIFY_PROMPT_IDS), "buttonify_prompt_active": ( BUTTONIFY_PROMPT_IDS[0] if BUTTONIFY_PROMPT_IDS else None @@ -1823,53 +1860,107 @@ def set_llm_override(): @settings_bp.route("/env_var/check", methods=["POST"]) def check_environment_variable(): - """API endpoint to check if an environment variable is set.""" - data = request.get_json() - if not data: - current_app.logger.warning( - "No JSON data received in environment variable check request" - ) - return jsonify({"error": "Invalid JSON payload"}), 400 - - env_var_name = data.get("env_var_name") - if not env_var_name: - current_app.logger.warning("Missing 'env_var_name' in request body") - return jsonify({"error": "Missing 'env_var_name' in request body."}), 400 + """Report existence/source for a server-recognised model API key. - current_app.logger.info(f"Checking environment variable: {env_var_name}") - value = os.getenv(env_var_name) - source = "process" if value else None + Browser input cannot turn this endpoint into an arbitrary environment or + ``.env`` reader, and no credential fragment is returned or logged. + """ - if not value: - try: - from dotenv import dotenv_values # type: ignore - from pathlib import Path - - repo_root = Path(__file__).resolve().parents[4] - env_path = repo_root / ".env" - if env_path.exists(): - values = dotenv_values(env_path) - raw = values.get(env_var_name) - if raw is not None: - value = str(raw) - source = "dotenv" - except Exception: - value = value or None + data = request.get_json(silent=True) or {} + env_var_name = _normalise_settings_key_check_env_var(data.get("env_var_name")) + if env_var_name is None: + return _jsonify_no_store( + {"error": "Unsupported model API key source."}, + 400, + ) - response_data: Dict[str, Any] = {"exists": bool(value)} + exists, source = _settings_key_presence(env_var_name) + current_app.logger.info( + "Model API key presence checked env_var=%s exists=%s source=%s", + env_var_name, + exists, + source or "none", + ) + response_data: Dict[str, Any] = {"exists": exists} if source: response_data["source"] = source - if value: - # Return a masked version of the key for verification - masked_value = f"{value[:5]}...{value[-4:]}" if len(value) > 9 else value - response_data["masked_value"] = masked_value - current_app.logger.info( - f"Environment variable {env_var_name} exists (masked: {masked_value})" - ) - else: - current_app.logger.info(f"Environment variable {env_var_name} not found") + return _jsonify_no_store(response_data, 200) + + +def _resolve_settings_api_key( + env_var_name: str | None, + *, + fallback_env_vars: tuple[str, ...] = (), +) -> str | None: + """Resolve a provider key for Settings probes without exposing it.""" + + names: list[str] = [] + for raw_name in (env_var_name, *fallback_env_vars): + name = str(raw_name or "").strip() + if name and name not in names: + names.append(name) + for name in names: + value = load_secret_from_env_or_file(name, f"{name}_FILE") + if value: + return value + dotenv_values = read_repo_dotenv_values(names) + for name in names: + value = dotenv_values.get(name) + if value: + return value + return None + + +_GEMINI_SETTINGS_KEY_ENV_VARS = ("GEMINI_API_KEY", "GOOGLE_API_KEY") + + +def _normalise_settings_key_check_env_var(value: object) -> str | None: + """Accept only key names selected by server-side model configuration.""" + + name = str(value or "").strip() + if name in _GEMINI_SETTINGS_KEY_ENV_VARS: + return name + try: + configured_openai_env_var = str(get_openai_env_var() or "").strip() + except Exception: + configured_openai_env_var = "OPENAI_API_KEY" + return name if name and name == configured_openai_env_var else None + + +def _settings_key_presence(env_var_name: str) -> tuple[bool, str | None]: + """Return credential presence without returning or logging credential data.""" + + candidates = ( + _GEMINI_SETTINGS_KEY_ENV_VARS + if env_var_name == "GEMINI_API_KEY" + else (env_var_name,) + ) + for name in candidates: + direct = str(os.getenv(name) or "").strip() + if direct: + return True, "process" + if read_secret_file(os.getenv(f"{name}_FILE")): + return True, "secret_file" + + dotenv_keys = tuple( + key + for name in candidates + for key in (name, f"{name}_FILE") + ) + dotenv_values = read_repo_dotenv_values(dotenv_keys) + for name in candidates: + if dotenv_values.get(name): + return True, "dotenv" + if read_secret_file(dotenv_values.get(f"{name}_FILE")): + return True, "dotenv_secret_file" + return False, None + + +def _normalise_gemini_settings_key_env_var(value: object) -> str | None: + """Accept only the server-supported Gemini key sources from browser input.""" - return jsonify(response_data), 200 + name = str(value or "").strip() + return name if name in _GEMINI_SETTINGS_KEY_ENV_VARS else None @settings_bp.route("/models/ollama", methods=["GET"]) @@ -1948,6 +2039,48 @@ def get_openai_models(): ) +@settings_bp.route("/models/gemini", methods=["GET"]) +def get_gemini_models(): + """Return Gemini generate-content models available to the configured key.""" + + api_key = _resolve_settings_api_key( + "GEMINI_API_KEY", + fallback_env_vars=("GOOGLE_API_KEY",), + ) + if not api_key: + return _jsonify_no_store( + { + "error": ( + "Gemini API key not found in GEMINI_API_KEY or legacy " + "GOOGLE_API_KEY." + ) + }, + 400, + ) + try: + models = GeminiClient(api_key=api_key).list_models() + if not models: + return _jsonify_no_store( + { + "error": ( + "Gemini returned no generate-content models for this API key." + ) + }, + 400, + ) + current_app.logger.info("Retrieved %s Gemini models", len(models)) + return _jsonify_no_store(models, 200) + except Exception as exc: + current_app.logger.warning( + "Could not list Gemini models (error_type=%s)", + type(exc).__name__, + ) + return _jsonify_no_store( + {"error": "Could not retrieve Gemini models with the configured key."}, + 400, + ) + + @settings_bp.route("/model_parameters/capabilities", methods=["GET"]) def get_model_parameter_capabilities(): provider = str(request.args.get("provider") or "").strip().lower() @@ -2343,6 +2476,71 @@ def verify_openai_api_key(): ) +@settings_bp.route("/gemini/verify", methods=["POST"]) +def verify_gemini_api_key(): + """Verify a configured Gemini key by listing its generate-content models.""" + + payload = request.get_json(silent=True) or {} + api_key_env_var = _normalise_gemini_settings_key_env_var( + payload.get("api_key_env_var") or "GEMINI_API_KEY" + ) + if not api_key_env_var: + return _jsonify_no_store( + { + "success": False, + "error": ( + "Gemini key source must be GEMINI_API_KEY or GOOGLE_API_KEY." + ), + }, + 400, + ) + api_key = _resolve_settings_api_key( + api_key_env_var, + fallback_env_vars=("GOOGLE_API_KEY",) + if api_key_env_var == "GEMINI_API_KEY" + else (), + ) + if not api_key: + return _jsonify_no_store( + { + "success": False, + "error": f"Gemini API key was not found in {api_key_env_var}.", + }, + 400, + ) + + current_app.logger.info( + "Attempting to verify Gemini API key from environment variable %s", + api_key_env_var, + ) + try: + models = GeminiClient(api_key=api_key).list_models() + if not models: + return _jsonify_no_store( + { + "success": False, + "error": ( + "Gemini returned no generate-content models for this API key." + ), + }, + 400, + ) + current_app.logger.info("Successfully retrieved %s Gemini models", len(models)) + return _jsonify_no_store({"success": True, "models": models}, 200) + except Exception as exc: + current_app.logger.warning( + "Gemini API key verification failed (error_type=%s)", + type(exc).__name__, + ) + return _jsonify_no_store( + { + "success": False, + "error": "Gemini API verification failed with the configured key.", + }, + 400, + ) + + @settings_bp.route("/ollama/hosts", methods=["GET"]) def get_ollama_hosts(): """API endpoint to get all configured Ollama hosts.""" @@ -3773,6 +3971,192 @@ def _classify_openai_probe_exception(exc: Exception) -> tuple[str, str]: ) +def _classify_gemini_probe_exception(exc: Exception) -> tuple[str, str]: + status_code = getattr(exc, "status_code", None) or getattr(exc, "code", None) + try: + status_code = int(status_code) if status_code is not None else None + except (TypeError, ValueError): + status_code = None + lowered = str(exc).strip().lower() + if status_code == 401 or "unauth" in lowered or "api key not valid" in lowered: + return "authentication_error", "Gemini authentication failed." + if status_code == 403 or "permission" in lowered or "forbidden" in lowered: + return "permission_denied", "Gemini access was denied for this model." + if status_code == 404 or "not found" in lowered: + return "model_not_found", "The selected Gemini model was not found." + if status_code == 429 or "rate limit" in lowered or "resource_exhausted" in lowered: + if "quota" in lowered or "resource_exhausted" in lowered: + return "quota_exhausted", "Gemini quota is exhausted." + return "rate_limited", "Gemini rate limit exceeded." + if status_code == 400 or "invalid argument" in lowered: + return "bad_request", "Gemini rejected the selected model probe." + if "connect" in lowered or "network" in lowered or "dns" in lowered: + return "connection_error", "Could not reach the Gemini API." + return "unexpected_error", "Unexpected Gemini model probe failure." + + +def _gemini_probe_api_surface(client: object, model: str) -> str: + metadata = getattr(client, "last_response_metadata", None) + observed = ( + str(metadata.get("api_surface") or "").strip() + if isinstance(metadata, dict) + else "" + ) + if observed in {"interactions", "gemini_generate_content"}: + return observed + canonical_model = str(model or "").strip().lower() + if canonical_model.startswith("models/"): + canonical_model = canonical_model.split("/", 1)[1] + return ( + "interactions" + if canonical_model == "gemini-3.7-flash" + or canonical_model.startswith("gemini-3.7-flash-") + else "gemini_generate_content" + ) + + +@settings_bp.route("/gemini/test_model", methods=["POST"]) +def test_gemini_model(): + """Probe whether an allowed Gemini model is usable with the configured key.""" + + payload = request.get_json(silent=True) or {} + api_key_env_var = _normalise_gemini_settings_key_env_var( + payload.get("api_key_env_var") or "GEMINI_API_KEY" + ) + 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": ( + "Gemini key source must be GEMINI_API_KEY or GOOGLE_API_KEY." + ), + }, + 400, + ) + if not requested_model: + return _jsonify_no_store( + { + "success": False, + "usable": False, + "failure_kind": "missing_model", + "reason": "No Gemini model is selected.", + }, + 400, + ) + + try: + assert_model_execution_allowed(provider="gemini", model=requested_model) + except ModelExecutionEligibilityError as exc: + return _jsonify_no_store( + { + "success": False, + "usable": False, + "model": exc.model or requested_model, + "failure_kind": exc.failure_kind, + "reason": str(exc), + }, + 403, + ) + + api_key = _resolve_settings_api_key( + api_key_env_var, + fallback_env_vars=("GOOGLE_API_KEY",) + if api_key_env_var == "GEMINI_API_KEY" + else (), + ) + if not api_key: + return _jsonify_no_store( + { + "success": False, + "usable": False, + "model": requested_model, + "failure_kind": "missing_api_key", + "reason": f"Gemini API key was not found in {api_key_env_var}.", + }, + 400, + ) + + model_parameters = normalise_model_parameters_for_storage( + payload.get(MODEL_PARAMETERS_KEY) or payload.get("modelParameters"), + provider="gemini", + model=requested_model, + api_surface="interactions", + include_registry=True, + ) + parameter_capabilities = build_model_parameter_capabilities( + provider="gemini", + model=requested_model, + api_surface="interactions", + include_registry=True, + ) + try: + client = GeminiClient(api_key=api_key, default_model=requested_model) + try: + available_models = client.list_models() + except Exception as exc: + available_models = [] + current_app.logger.warning( + "[gemini_test_model] Failed to list Gemini models before probe: %s", + exc, + ) + if available_models and requested_model not in available_models: + return _jsonify_no_store( + { + "success": True, + "usable": False, + "model": requested_model, + MODEL_PARAMETERS_KEY: model_parameters, + "model_parameter_capabilities": parameter_capabilities, + "failure_kind": "model_unavailable", + "reason": ( + f"{requested_model} is not present in the models available " + "to this Gemini API key." + ), + } + ) + client.generate( + "Reply exactly with OK.", + model=requested_model, + llm_params=model_parameters or None, + ) + api_surface = _gemini_probe_api_surface(client, requested_model) + 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 Gemini model completed a live probe successfully." + ), + "api_surface": api_surface, + } + ) + except Exception as exc: + failure_kind, reason = _classify_gemini_probe_exception(exc) + current_app.logger.warning( + "[gemini_test_model] Probe failed for %s (error_type=%s)", + requested_model, + type(exc).__name__, + ) + return _jsonify_no_store( + { + "success": True, + "usable": False, + "model": requested_model, + MODEL_PARAMETERS_KEY: model_parameters, + "model_parameter_capabilities": parameter_capabilities, + "failure_kind": failure_kind, + "reason": reason, + } + ) + + @settings_bp.route("/ollama/test_model", methods=["POST"]) def test_ollama_model(): """Probe whether a selected Ollama model is reachable and usable.""" diff --git a/src/backend/services/file_copy_interpretation_service.py b/src/backend/services/file_copy_interpretation_service.py index 89fb6b162..79240048d 100644 --- a/src/backend/services/file_copy_interpretation_service.py +++ b/src/backend/services/file_copy_interpretation_service.py @@ -15,7 +15,10 @@ from datetime import datetime, timezone from typing import Any, Mapping, Sequence -from ..languagemodels.model_defaults import DEFAULT_OPENAI_MODEL +from ..languagemodels.model_defaults import DEFAULT_GEMINI_MODEL, DEFAULT_OPENAI_MODEL +from ..languagemodels.structured_tool_calling.transport import ( + sanitise_transport_telemetry_text, +) from .llm_api_key_resolution import get_gemini_api_key logger = logging.getLogger(__name__) @@ -1140,7 +1143,7 @@ def _describe_image_with_gemini( "model": model, } - target_model = model or "gemini-2.0-flash" + target_model = model or DEFAULT_GEMINI_MODEL eligibility_denial = _external_image_model_execution_denial( provider="gemini", model=target_model, @@ -1148,11 +1151,16 @@ def _describe_image_with_gemini( if eligibility_denial is not None: return eligibility_denial mime = _normalise_optional_text(content_type) or "image/png" + client = None try: - genai.configure(api_key=api_key) # type: ignore[attr-defined] - model_instance = genai.GenerativeModel(target_model) # type: ignore[attr-defined] - response = model_instance.generate_content( - [prompt, {"mime_type": mime, "data": bytes(data_bytes)}] + client = genai.Client(api_key=api_key) # type: ignore[attr-defined] + image_part = genai.types.Part.from_bytes( # type: ignore[attr-defined] + data=bytes(data_bytes), + mime_type=mime, + ) + response = client.models.generate_content( + model=target_model, + contents=[prompt, image_part], ) response_text = getattr(response, "text", None) description = response_text.strip() if isinstance(response_text, str) else None @@ -1175,10 +1183,17 @@ def _describe_image_with_gemini( return { "description": None, "method": "gemini_vision_failed", - "error": str(exc), + "error": sanitise_transport_telemetry_text(str(exc)), "provider": "gemini", "model": target_model, } + finally: + close = getattr(client, "close", None) + if callable(close): + try: + close() + except Exception: + logger.debug("Failed to close Gemini image client") def describe_image_semantics( diff --git a/src/backend/services/llm_api_key_resolution.py b/src/backend/services/llm_api_key_resolution.py index 786234a4a..4af61caac 100644 --- a/src/backend/services/llm_api_key_resolution.py +++ b/src/backend/services/llm_api_key_resolution.py @@ -2,27 +2,28 @@ from __future__ import annotations -import os +from ..utils.runtime_env import load_secret_from_env_or_file -_GEMINI_API_KEY_ENV_VARS: tuple[str, ...] = ("GEMINI_API_KEY", "GOOGLE_API_KEY") +_GEMINI_API_KEY_ENV_VARS: tuple[str, ...] = ( + "GEMINI_API_KEY", + "GOOGLE_API_KEY", +) def _first_nonempty_env_value(names: tuple[str, ...]) -> str | None: for name in names: - value = os.getenv(name) - if not isinstance(value, str): - continue - cleaned = value.strip() - if cleaned: - return cleaned + value = load_secret_from_env_or_file(name, f"{name}_FILE") + if value: + return value return None def get_gemini_api_key() -> str | None: """Return the configured Gemini API key. - Prefer `GEMINI_API_KEY` while still accepting the legacy `GOOGLE_API_KEY` - name so existing developer environments keep working during the migration. + Prefer ``GEMINI_API_KEY`` (or ``GEMINI_API_KEY_FILE``) while still + accepting the legacy ``GOOGLE_API_KEY`` pair so existing developer + environments keep working during the migration. """ return _first_nonempty_env_value(_GEMINI_API_KEY_ENV_VARS) diff --git a/src/backend/services/llm_usage_cost_service.py b/src/backend/services/llm_usage_cost_service.py index 05e7ef29d..e3b3e2006 100644 --- a/src/backend/services/llm_usage_cost_service.py +++ b/src/backend/services/llm_usage_cost_service.py @@ -10,6 +10,7 @@ import math from collections.abc import Mapping, Sequence +from datetime import UTC, datetime from decimal import Decimal, DecimalException, InvalidOperation from typing import Any @@ -60,6 +61,24 @@ def _amount(value: Decimal | None) -> float | None: return amount if math.isfinite(amount) else None +def _utc_datetime(value: Any) -> datetime | None: + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + raw = value.strip() + try: + parsed = datetime.fromisoformat( + f"{raw[:-1]}+00:00" if raw.endswith("Z") else raw + ) + except ValueError: + return None + else: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + def normalise_llm_usage( value: Any, *, @@ -178,7 +197,7 @@ def _exact_pricing( def _pricing_metadata( pricing: Mapping[str, Any], *, effective_model: str ) -> dict[str, Any]: - return { + metadata = { "schema_version": _text(pricing.get("schema_version")), "version": _text(pricing.get("version")), "source": _text(pricing.get("source")), @@ -186,6 +205,10 @@ def _pricing_metadata( "model_id": _text(pricing.get("model_id")) or effective_model, "unit_tokens": _count(pricing.get("unit_tokens")), } + effective_until_utc = _text(pricing.get("effective_until_utc")) + if effective_until_utc: + metadata["effective_until_utc"] = effective_until_utc + return metadata def _estimate_call_cost( @@ -198,6 +221,7 @@ def _estimate_call_cost( effective_service_tier: str | None, connection_id: str | None, pricing_context_input_tokens: int | None, + pricing_as_of: datetime, ) -> dict[str, Any]: if provider_request_sent is False: return { @@ -238,6 +262,43 @@ def _estimate_call_cost( currency = _text(pricing.get("currency")) currency = currency.upper() if currency else None unit_tokens = _count(pricing.get("unit_tokens")) + effective_at_raw = _text(pricing.get("effective_at_utc")) + effective_until_raw = _text(pricing.get("effective_until_utc")) + effective_at = _utc_datetime(effective_at_raw) + effective_until = _utc_datetime(effective_until_raw) + if (effective_at_raw and effective_at is None) or ( + effective_until_raw and effective_until is None + ) or ( + effective_at is not None + and effective_until is not None + and effective_at >= effective_until + ): + return { + "status": "unavailable", + "amount": None, + "known_amount": None, + "currency": currency, + "reason": "pricing_effective_window_invalid", + "pricing": metadata, + } + if effective_at is not None and pricing_as_of < effective_at: + return { + "status": "unavailable", + "amount": None, + "known_amount": None, + "currency": currency, + "reason": "pricing_not_yet_effective", + "pricing": metadata, + } + if effective_until is not None and pricing_as_of >= effective_until: + return { + "status": "unavailable", + "amount": None, + "known_amount": None, + "currency": currency, + "reason": "pricing_expired", + "pricing": metadata, + } applicability = pricing.get("applicability") if isinstance(applicability, Mapping): allowed_tiers = applicability.get("effective_service_tiers") @@ -479,7 +540,11 @@ def _deduplicate(value: Any) -> tuple[list[Mapping[str, Any]], int, int]: def _normalise_call( - call: Mapping[str, Any], *, source_index: int, model_registry: Any + call: Mapping[str, Any], + *, + source_index: int, + model_registry: Any, + pricing_as_of: datetime, ) -> dict[str, Any]: provider_request_sent = ( call.get("provider_request_sent") @@ -527,6 +592,7 @@ def _normalise_call( effective_service_tier=effective_service_tier, connection_id=connection_id, pricing_context_input_tokens=pricing_context_input_tokens, + pricing_as_of=pricing_as_of, ) return { "call_id": _text(call.get("call_id")), @@ -770,12 +836,21 @@ def build_llm_usage_cost_summary( llm_calls: Any, *, model_registry: Any = None, + as_of: datetime | None = None, ) -> dict[str, Any]: """Build one content-free summary from one canonical call sequence.""" unique, raw_count, duplicate_count = _deduplicate(llm_calls) + pricing_as_of = _utc_datetime(as_of) if as_of is not None else datetime.now(UTC) + if pricing_as_of is None: + raise ValueError("as_of must be a valid datetime") calls = [ - _normalise_call(call, source_index=index, model_registry=model_registry) + _normalise_call( + call, + source_index=index, + model_registry=model_registry, + pricing_as_of=pricing_as_of, + ) for index, call in enumerate(unique) ] model_identities, omitted_identity_count = _model_identity_summary(calls) diff --git a/src/backend/services/model_parameter_service.py b/src/backend/services/model_parameter_service.py index 3cda0bfe4..0f48059c0 100644 --- a/src/backend/services/model_parameter_service.py +++ b/src/backend/services/model_parameter_service.py @@ -15,6 +15,7 @@ MODEL_PARAMETER_REASONING_EFFORT = "reasoning_effort" 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") _OPENAI_REASONING_EFFORT_API_MAPPINGS = { "responses": { @@ -27,6 +28,17 @@ }, } +_GEMINI_REASONING_EFFORT_API_MAPPINGS = { + "interactions": { + "type": "nested_object", + "path": ["generation_config", "thinking_level"], + }, + "gemini_generate_content": { + "type": "nested_object", + "path": ["thinking_config", "thinking_level"], + }, +} + _MODEL_PARAMETER_ALIASES = { "reasoningeffort": MODEL_PARAMETER_REASONING_EFFORT, "reasoning_effort": MODEL_PARAMETER_REASONING_EFFORT, @@ -101,6 +113,20 @@ def _openai_reasoning_effort_supported(model: str | None) -> bool: return lowered.startswith(("gpt-5", "o1", "o3", "o4")) +def _gemini_reasoning_effort_supported(model: str | None) -> bool: + """Return whether the model has Google's discrete thinking-level control.""" + + model_id = _clean_text(model) + if not model_id: + return False + lowered = model_id.lower() + if lowered.startswith("gemini:"): + lowered = lowered.split(":", 1)[1].strip() + if lowered.startswith("models/"): + lowered = lowered.split("/", 1)[1].strip() + return lowered == "gemini-3.7-flash" or lowered.startswith("gemini-3.7-flash-") + + def _parameter_capability( *, parameter_id: str, @@ -143,18 +169,31 @@ def _provider_default_reasoning_effort_capability( provider: str | None, model: str | None, ) -> dict[str, Any]: - supported = provider == "openai" and _openai_reasoning_effort_supported(model) + openai_supported = provider == "openai" and _openai_reasoning_effort_supported( + model + ) + gemini_supported = provider == "gemini" and _gemini_reasoning_effort_supported( + model + ) + supported = openai_supported or gemini_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 + else: + allowed_values = [] + provider_api_mapping = {} return _parameter_capability( parameter_id=MODEL_PARAMETER_REASONING_EFFORT, supported=supported, - allowed_values=list(OPENAI_REASONING_EFFORT_VALUES) if supported else [], + allowed_values=allowed_values, fixed_value=None, read_only=False, source="provider_default_metadata" if supported else "unsupported", sources=["provider_default_metadata"] if supported else ["unsupported"], - provider_api_mapping=( - _OPENAI_REASONING_EFFORT_API_MAPPINGS if provider == "openai" else {} - ), + provider_api_mapping=provider_api_mapping, ) @@ -466,3 +505,24 @@ def openai_chat_completions_kwargs_from_model_parameters( include_registry=True, profile_concept_id=profile_concept_id, ) + + +def gemini_kwargs_from_model_parameters( + raw: Any, + *, + model: str | None, + api_surface: str, + profile_concept_id: str | None = None, +) -> dict[str, Any]: + """Project Von's neutral reasoning effort onto a Gemini API surface.""" + + if api_surface not in {"interactions", "gemini_generate_content"}: + return {} + return provider_kwargs_from_model_parameters( + raw, + provider="gemini", + model=model, + api_surface=api_surface, + include_registry=True, + profile_concept_id=profile_concept_id, + ) diff --git a/src/backend/services/model_registry_release_vontology_service.py b/src/backend/services/model_registry_release_vontology_service.py new file mode 100644 index 000000000..5869b2cbc --- /dev/null +++ b/src/backend/services/model_registry_release_vontology_service.py @@ -0,0 +1,689 @@ +"""Governed publication for versioned model-registry release inputs. + +The repo bundle is release material, not live authority. Publication requires +an actor-bound governed-method invoker supplied by an administration surface; +this module never binds actor identity, writes a database directly, or runs at +startup. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +from collections.abc import Callable, Mapping, Sequence +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +MODEL_REGISTRY_RELEASE_BUNDLE_SCHEMA_VERSION = "model_registry_release_bundle.v1" +DEFAULT_GEMINI_3_7_FLASH_RELEASE_PATH = ( + Path(__file__).resolve().parents[1] + / "vontology" + / "seed_bundles" + / "model_registry_gemini_3_7_flash_release.json.in" +) + +GovernedMethodInvoker = Callable[[str, Mapping[str, Any]], Mapping[str, Any]] +ConceptExistsReader = Callable[[str], bool] +RegistrySnapshotReader = Callable[[], Mapping[str, Any]] + +_CREATE_KINDS = {"individual": "instance", "predicate": "predicate", "type": "type"} +_EXPECTED_FIELDS = ( + "provider", + "model_id", + "registry_entry_id", + "model_concept_id", + "api_profile_id", + "api_surface", + "structured_tool_calling", + "tool_continuation_mode", + "response_storage_policy", + "connection_id", + "pricing_version", + "capabilities_version", +) +_PROFILE_PREDICATES = { + "api_surface": "#V#has_api_surface", + "structured_tool_calling": "#V#has_structured_tool_calling_support", + "tool_continuation_mode": "#V#has_tool_continuation_mode", + "response_storage_policy": "#V#has_response_storage_policy", + "connection_id": "#V#has_provider_connection_id", +} + + +class ModelRegistryReleaseError(RuntimeError): + """Typed failure at the release-input or publication boundary.""" + + def __init__(self, code: str, *, details: Mapping[str, Any] | None = None) -> None: + self.code = code + self.details = copy.deepcopy(dict(details or {})) + super().__init__(code) + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": "model_registry_release_error.v1", + "error_code": self.code, + "details": copy.deepcopy(self.details), + } + + +def _fail(code: str, **details: Any) -> None: + raise ModelRegistryReleaseError(code, details=details) + + +def _text(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _concept_id(value: Any) -> str: + resolved = _text(value) + if not resolved.startswith("#V#") or len(resolved) == 3: + _fail("model_registry_release_concept_id_invalid", concept_id=resolved) + return resolved + + +def _items(bundle: Mapping[str, Any], field: str) -> list[dict[str, Any]]: + values = bundle.get(field) + if not isinstance(values, Sequence) or isinstance(values, (str, bytes)): + _fail("model_registry_release_section_invalid", field=field) + if not all(isinstance(item, Mapping) for item in values): + _fail("model_registry_release_section_item_invalid", field=field) + return [dict(item) for item in values] + + +def _json_text(value: Mapping[str, Any]) -> str: + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def _positive_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 + + +def _utc_datetime(value: Any) -> datetime | None: + raw = _text(value) + if not raw: + return None + try: + parsed = datetime.fromisoformat(f"{raw[:-1]}+00:00" if raw.endswith("Z") else raw) + except ValueError: + return None + if parsed.tzinfo is None: + return None + return parsed.astimezone(UTC) + + +def _normalise_text_relation(spec: Mapping[str, Any]) -> dict[str, Any]: + relation = dict(spec) + relation["subject_concept_id"] = _concept_id(relation.get("subject_concept_id")) + predicate = _text(relation.get("predicate")) + relation["resolved_predicate"] = _concept_id( + "#V#hasName" if predicate == "hasName" else predicate + ) + mode = _text(relation.get("mode")) + if mode not in {"additive", "singleton"}: + _fail("model_registry_release_text_mode_invalid", mode=mode) + has_text = bool(_text(relation.get("text"))) + has_json = isinstance(relation.get("json"), Mapping) + if has_text == has_json: + _fail("model_registry_release_text_value_invalid") + relation["resolved_text"] = ( + _text(relation.get("text")) if has_text else _json_text(relation["json"]) + ) + relation["lang"] = _text(relation.get("lang")) or "en-NZ" + return relation + + +def validate_model_registry_release_bundle( + payload: Mapping[str, Any], +) -> dict[str, Any]: + """Validate exact dependency closure and the read-back contract.""" + + if not isinstance(payload, Mapping): + _fail("model_registry_release_not_mapping") + bundle = copy.deepcopy(dict(payload)) + if bundle.get("schema_version") != MODEL_REGISTRY_RELEASE_BUNDLE_SCHEMA_VERSION: + _fail("model_registry_release_schema_unsupported") + if not _text(bundle.get("release_version")): + _fail("model_registry_release_version_required") + if bundle.get("authority_role") != "repo_release_input_only": + _fail("model_registry_release_authority_role_invalid") + + required_values = bundle.get("required_existing_concept_ids") + if not isinstance(required_values, Sequence) or isinstance(required_values, str): + _fail("model_registry_release_dependencies_required") + required = {_concept_id(item) for item in required_values} + concepts = _items(bundle, "concepts") + created: dict[str, dict[str, Any]] = {} + for concept in concepts: + concept_id = _concept_id(concept.get("concept_id")) + kind = _text(concept.get("kind")) + if concept_id in required or concept_id in created or kind not in _CREATE_KINDS: + _fail("model_registry_release_concept_invalid", concept_id=concept_id) + if not _text(concept.get("name")): + _fail("model_registry_release_concept_name_required", concept_id=concept_id) + created[concept_id] = concept + known = required | set(created) + for concept_id, concept in created.items(): + parent = ( + "#V#predicate" + if concept["kind"] == "predicate" + else _concept_id(concept.get("instance_of_type")) + ) + if parent not in known: + _fail( + "model_registry_release_dependency_undeclared", + concept_id=concept_id, + dependency_concept_id=parent, + ) + + relationships = _items(bundle, "relationships") + relationship_keys: set[tuple[str, str, str]] = set() + for relation in relationships: + key = ( + _concept_id(relation.get("source_id")), + _concept_id(relation.get("predicate")), + _concept_id(relation.get("target_id")), + ) + if key in relationship_keys or not set(key) <= known: + _fail("model_registry_release_relationship_invalid", relationship=list(key)) + relationship_keys.add(key) + + text_relations = [ + _normalise_text_relation(item) for item in _items(bundle, "text_relations") + ] + if any( + relation["subject_concept_id"] not in known + or relation["resolved_predicate"] not in known + for relation in text_relations + ): + _fail("model_registry_release_text_dependency_undeclared") + + model = bundle.get("model") + expected = bundle.get("expected_read_back") + if not isinstance(model, Mapping) or not isinstance(expected, Mapping): + _fail("model_registry_release_contract_required") + if any(not _text(expected.get(field)) for field in _EXPECTED_FIELDS): + _fail("model_registry_release_read_back_field_required") + if ( + expected["provider"] != model.get("provider") + or expected["model_id"] != model.get("model_id") + or expected["model_concept_id"] != model.get("concept_id") + or expected["registry_entry_id"] != model.get("registry_entry_id") + or expected["response_storage_policy"] != "disabled" + or expected["tool_continuation_mode"] != "stateless" + ): + _fail("model_registry_release_read_back_contract_invalid") + for concept_id in ( + expected["model_concept_id"], + expected["registry_entry_id"], + expected["api_profile_id"], + ): + if concept_id not in created: + _fail("model_registry_release_identity_not_created", concept_id=concept_id) + + singleton = { + (item["subject_concept_id"], item["resolved_predicate"]): item + for item in text_relations + if item["mode"] == "singleton" + } + required_text = { + (expected["registry_entry_id"], "#V#has_model_id"): expected["model_id"], + **{ + (expected["api_profile_id"], predicate): expected[field] + for field, predicate in _PROFILE_PREDICATES.items() + }, + } + if any( + singleton.get(key, {}).get("resolved_text") != value + for key, value in required_text.items() + ): + _fail("model_registry_release_text_contract_incomplete") + for predicate, field in ( + ("#V#has_model_pricing_json", "pricing_version"), + ("#V#has_model_capabilities_json", "capabilities_version"), + ): + relation = singleton.get((expected["registry_entry_id"], predicate), {}) + if ( + not isinstance(relation.get("json"), Mapping) + or relation["json"].get("version") != expected[field] + ): + _fail( + "model_registry_release_json_contract_incomplete", predicate=predicate + ) + pricing = singleton[(expected["registry_entry_id"], "#V#has_model_pricing_json")][ + "json" + ] + rates = pricing.get("rates") + applicability = pricing.get("applicability") + effective_at = _utc_datetime(pricing.get("effective_at_utc")) + effective_until = _utc_datetime(pricing.get("effective_until_utc")) + if ( + pricing.get("schema_version") != "llm_model_pricing.v1" + or pricing.get("provider") != expected["provider"] + or pricing.get("model_id") != expected["model_id"] + or not _positive_number(pricing.get("unit_tokens")) + or not isinstance(applicability, Mapping) + or applicability.get("effective_service_tiers") != ["standard"] + or applicability.get("connection_ids") != [expected["connection_id"]] + or effective_at is None + or effective_until is None + or effective_at >= effective_until + or not isinstance(rates, Mapping) + or not _positive_number(rates.get("input_tokens")) + or not _positive_number(rates.get("output_tokens")) + ): + _fail("model_registry_release_pricing_contract_invalid") + capabilities = singleton[ + (expected["registry_entry_id"], "#V#has_model_capabilities_json") + ]["json"] + if ( + capabilities.get("schema_version") != "llm_model_capabilities.v1" + or capabilities.get("provider") != expected["provider"] + or capabilities.get("model_id") != expected["model_id"] + or not _positive_number(capabilities.get("input_token_limit")) + or not _positive_number(capabilities.get("output_token_limit")) + ): + _fail("model_registry_release_capabilities_contract_invalid") + + required_edges = { + ( + "#V#default_model_registry", + "#V#has_model_entry", + expected["registry_entry_id"], + ), + ( + expected["registry_entry_id"], + "#V#refers_to_model", + expected["model_concept_id"], + ), + ( + expected["registry_entry_id"], + "#V#has_model_api_profile", + expected["api_profile_id"], + ), + ( + expected["model_concept_id"], + "#V#has_provider", + f"#V#{expected['provider']}_provider", + ), + } + if not required_edges <= relationship_keys: + _fail("model_registry_release_graph_contract_incomplete") + + bundle.update( + { + "required_existing_concept_ids": sorted(required), + "concepts": concepts, + "relationships": relationships, + "text_relations": text_relations, + } + ) + return bundle + + +def load_model_registry_release_bundle( + asset_path: str | Path | None = None, +) -> dict[str, Any]: + path = Path(asset_path or DEFAULT_GEMINI_3_7_FLASH_RELEASE_PATH) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ModelRegistryReleaseError( + "model_registry_release_asset_unreadable", + details={"asset_path": str(path)}, + ) from exc + bundle = validate_model_registry_release_bundle(payload) + bundle["asset_path"] = str(path) + return bundle + + +def _canonical_concept_exists(concept_id: str) -> bool: + from .concept_service import get_concept_by_id + + return isinstance(get_concept_by_id(concept_id), Mapping) + + +def inspect_model_registry_release_dependencies( + bundle: Mapping[str, Any], *, concept_exists: ConceptExistsReader | None = None +) -> dict[str, Any]: + validated = validate_model_registry_release_bundle(bundle) + reader = concept_exists or _canonical_concept_exists + required = list(validated["required_existing_concept_ids"]) + created = [item["concept_id"] for item in validated["concepts"]] + existing_required = [item for item in required if reader(item)] + missing = [item for item in required if item not in existing_required] + return { + "schema_version": "model_registry_release_dependency_read_back.v1", + "success": not missing, + "required_existing_concept_ids": required, + "existing_required_concept_ids": existing_required, + "missing_required_concept_ids": missing, + "release_created_concept_ids": created, + "already_existing_release_concept_ids": [ + item for item in created if reader(item) + ], + } + + +def _operation_id(version: str, method: str, identity: str) -> str: + digest = hashlib.sha256(f"{version}\0{method}\0{identity}".encode()).hexdigest() + return f"model-registry-release:{version}:{digest[:20]}" + + +def build_model_registry_release_plan( + bundle: Mapping[str, Any], *, concept_exists: ConceptExistsReader | None = None +) -> dict[str, Any]: + validated = validate_model_registry_release_bundle(bundle) + version = validated["release_version"] + operations: list[dict[str, Any]] = [] + existing: list[str] = [] + + def add(method: str, identity: str, arguments: Mapping[str, Any]) -> None: + operations.append( + { + "operation_id": _operation_id(version, method, identity), + "method_name": method, + "arguments": copy.deepcopy(dict(arguments)), + } + ) + + for concept in validated["concepts"]: + concept_id = concept["concept_id"] + if concept_exists is not None and concept_exists(concept_id): + existing.append(concept_id) + continue + parent = ( + "#V#predicate" + if concept["kind"] == "predicate" + else concept["instance_of_type"] + ) + spec = { + key: concept[key] + for key in ("concept_id", "name", "description") + if _text(concept.get(key)) + } + spec["kind"] = _CREATE_KINDS[concept["kind"]] + if concept["kind"] == "individual": + spec["instance_of_type"] = parent + add( + "create_concepts", + concept_id, + { + "parent_id": parent, + "concepts": [spec], + "duplicate_resolution_mode": "canonical_id_only", + "scope_mode": "global_general", + }, + ) + expected = validated["expected_read_back"] + activation_key = ( + "#V#default_model_registry", + "#V#has_model_entry", + expected["registry_entry_id"], + ) + activation_relation: dict[str, Any] | None = None + for relation in validated["relationships"]: + relation_key = ( + relation["source_id"], + relation["predicate"], + relation["target_id"], + ) + if relation_key == activation_key: + activation_relation = relation + continue + identity = "|".join( + (relation["source_id"], relation["predicate"], relation["target_id"]) + ) + add( + "add_relationship", + identity, + { + "source_id": relation["source_id"], + "predicate": relation["predicate"], + "target": relation["target_id"], + }, + ) + for relation in validated["text_relations"]: + method = ( + "upsert_singleton_text_relation" + if relation["mode"] == "singleton" + else "upsert_text_relation" + ) + arguments = { + "concept_id": relation["subject_concept_id"], + "predicate": relation["resolved_predicate"], + "text": relation["resolved_text"], + "language": relation["lang"], + } + if relation["mode"] == "singleton": + arguments.update({"policy": "replace_others", "garbage_collect": True}) + add(method, "|".join(str(value) for value in arguments.values()), arguments) + if activation_relation is None: + _fail("model_registry_release_activation_edge_missing") + add( + "add_relationship", + "|".join(activation_key), + { + "source_id": activation_relation["source_id"], + "predicate": activation_relation["predicate"], + "target": activation_relation["target_id"], + }, + ) + return { + "schema_version": "model_registry_release_plan.v1", + "release_version": version, + "source_tag": validated.get("source_tag"), + "authority_requirement": "authenticated_global_ontology_publication", + "operations": operations, + "already_existing_release_concept_ids": existing, + } + + +def verify_model_registry_release_read_back( + bundle: Mapping[str, Any], snapshot: Mapping[str, Any] +) -> dict[str, Any]: + validated = validate_model_registry_release_bundle(bundle) + expected = validated["expected_read_back"] + models = snapshot.get("models") if isinstance(snapshot, Mapping) else [] + models = ( + models if isinstance(models, Sequence) and not isinstance(models, str) else [] + ) + entry = next( + ( + item + for item in models + if isinstance(item, Mapping) + and item.get("registry_entry_id") == expected["registry_entry_id"] + ), + {}, + ) + profiles = entry.get("api_profiles") if isinstance(entry, Mapping) else [] + profile = next( + ( + item + for item in (profiles or []) + if isinstance(item, Mapping) + and item.get("profile_concept_id") == expected["api_profile_id"] + ), + {}, + ) + pricing = entry.get("pricing") if isinstance(entry.get("pricing"), Mapping) else {} + capabilities = ( + entry.get("capabilities") + if isinstance(entry.get("capabilities"), Mapping) + else {} + ) + expected_pricing = next( + relation["json"] + for relation in validated["text_relations"] + if relation["subject_concept_id"] == expected["registry_entry_id"] + and relation["resolved_predicate"] == "#V#has_model_pricing_json" + ) + expected_capabilities = next( + relation["json"] + for relation in validated["text_relations"] + if relation["subject_concept_id"] == expected["registry_entry_id"] + and relation["resolved_predicate"] == "#V#has_model_capabilities_json" + ) + actual = { + "provider": entry.get("provider"), + "model_id": entry.get("model_id"), + "registry_entry_id": entry.get("registry_entry_id"), + "model_concept_id": entry.get("concept_id"), + "api_profile_id": profile.get("profile_concept_id"), + **{field: profile.get(field) for field in _PROFILE_PREDICATES}, + "pricing_version": pricing.get("version"), + "capabilities_version": capabilities.get("version"), + "pricing_payload": copy.deepcopy(dict(pricing)), + "capabilities_payload": copy.deepcopy(dict(capabilities)), + } + mismatches = { + field: {"expected": expected[field], "actual": actual.get(field)} + for field in _EXPECTED_FIELDS + if actual.get(field) != expected[field] + } + for field, expected_payload in ( + ("pricing_payload", expected_pricing), + ("capabilities_payload", expected_capabilities), + ): + if actual[field] != expected_payload: + mismatches[field] = { + "expected": copy.deepcopy(dict(expected_payload)), + "actual": copy.deepcopy(dict(actual[field])), + } + return { + "schema_version": "model_registry_release_read_back.v1", + "success": not mismatches, + "matched": not mismatches, + "release_version": validated["release_version"], + "snapshot_source": snapshot.get("source"), + "expected": copy.deepcopy(dict(expected)), + "actual": actual, + "mismatches": mismatches, + } + + +def _fresh_registry_snapshot() -> Mapping[str, Any]: + from .model_registry_service import ( + clear_model_registry_snapshot_caches, + get_model_registry_snapshot, + ) + + clear_model_registry_snapshot_caches(remove_disk=True) + return get_model_registry_snapshot() + + +def _operation_succeeded(method: str, result: Mapping[str, Any]) -> bool: + if result.get("success") is False or result.get("effect_status") in { + "failed", + "indeterminate", + "partial", + }: + return False + if method == "create_concepts": + return result.get("total") == result.get("successful") == 1 + return result.get("success") is True + + +def publish_model_registry_release_bundle( + *, + invoke_governed_method: GovernedMethodInvoker, + asset_path: str | Path | None = None, + concept_exists: ConceptExistsReader | None = None, + snapshot_reader: RegistrySnapshotReader | None = None, + refreshed_snapshot_reader: RegistrySnapshotReader | None = None, +) -> dict[str, Any]: + """Publish through governed calls and require fresh canonical read-back.""" + + bundle = load_model_registry_release_bundle(asset_path) + exists = concept_exists or _canonical_concept_exists + dependency_read_back = inspect_model_registry_release_dependencies( + bundle, concept_exists=exists + ) + if not dependency_read_back["success"]: + _fail( + "model_registry_release_dependency_missing", + missing_required_concept_ids=dependency_read_back[ + "missing_required_concept_ids" + ], + ) + read_before = snapshot_reader or _fresh_registry_snapshot + before = verify_model_registry_release_read_back(bundle, read_before()) + if before["matched"]: + return { + "schema_version": "model_registry_release_publication.v1", + "success": True, + "changed": False, + "idempotent": True, + "release_version": bundle["release_version"], + "operation_results": [], + "dependency_read_back": dependency_read_back, + "canonical_read_back": before, + } + + plan = build_model_registry_release_plan(bundle, concept_exists=exists) + receipts: list[dict[str, Any]] = [] + changed = False + for operation in plan["operations"]: + result = invoke_governed_method( + operation["method_name"], copy.deepcopy(operation["arguments"]) + ) + if not isinstance(result, Mapping) or not _operation_succeeded( + operation["method_name"], result if isinstance(result, Mapping) else {} + ): + _fail( + "model_registry_release_governed_operation_failed", + operation_id=operation["operation_id"], + method_name=operation["method_name"], + completed_operation_ids=[item["operation_id"] for item in receipts], + ) + changed = changed or any( + bool(result.get(field)) + for field in ("changed", "added", "relation_created", "successful") + ) + receipts.append( + { + "operation_id": operation["operation_id"], + "method_name": operation["method_name"], + "result": copy.deepcopy(dict(result)), + } + ) + + read_after = refreshed_snapshot_reader or _fresh_registry_snapshot + after = verify_model_registry_release_read_back(bundle, read_after()) + if not after["matched"]: + _fail( + "model_registry_release_canonical_read_back_failed", + completed_operation_ids=[item["operation_id"] for item in receipts], + canonical_read_back=after, + ) + return { + "schema_version": "model_registry_release_publication.v1", + "success": True, + "changed": changed, + "idempotent": not changed, + "release_version": bundle["release_version"], + "operation_results": receipts, + "dependency_read_back": dependency_read_back, + "canonical_read_back": after, + } + + +__all__ = [ + "DEFAULT_GEMINI_3_7_FLASH_RELEASE_PATH", + "MODEL_REGISTRY_RELEASE_BUNDLE_SCHEMA_VERSION", + "ModelRegistryReleaseError", + "build_model_registry_release_plan", + "inspect_model_registry_release_dependencies", + "load_model_registry_release_bundle", + "publish_model_registry_release_bundle", + "validate_model_registry_release_bundle", + "verify_model_registry_release_read_back", +] diff --git a/src/backend/services/model_registry_service.py b/src/backend/services/model_registry_service.py index 231e10a9b..4847e7aaf 100644 --- a/src/backend/services/model_registry_service.py +++ b/src/backend/services/model_registry_service.py @@ -29,6 +29,7 @@ PRED_CONSTRAINS_MODEL_PARAMETER = "#V#constrains_model_parameter" PRED_HAS_MODEL_ID = "#V#has_model_id" PRED_HAS_MODEL_PRICING_JSON = "#V#has_model_pricing_json" +PRED_HAS_MODEL_CAPABILITIES_JSON = "#V#has_model_capabilities_json" PRED_HAS_API_SURFACE = "#V#has_api_surface" PRED_HAS_STRUCTURED_TOOL_CALLING = "#V#has_structured_tool_calling_support" PRED_HAS_TOOL_CONTINUATION_MODE = "#V#has_tool_continuation_mode" @@ -798,6 +799,13 @@ def _resolve_model_entry_from_graph( ) or "" ) + capabilities = _parse_registry_json( + _get_first_text( + registry_entry_id, + predicate=PRED_HAS_MODEL_CAPABILITIES_JSON, + ) + or "" + ) entry = { "model_id": model_id, "model_aliases": model_aliases, @@ -809,6 +817,8 @@ def _resolve_model_entry_from_graph( } if pricing is not None: entry["pricing"] = dict(pricing) + if capabilities is not None: + entry["capabilities"] = dict(capabilities) return entry diff --git a/src/backend/vontology/seed_bundles/model_registry_gemini_3_7_flash_release.json.in b/src/backend/vontology/seed_bundles/model_registry_gemini_3_7_flash_release.json.in new file mode 100644 index 000000000..54245555a --- /dev/null +++ b/src/backend/vontology/seed_bundles/model_registry_gemini_3_7_flash_release.json.in @@ -0,0 +1,254 @@ +{ + "schema_version": "model_registry_release_bundle.v1", + "release_version": "1", + "source_tag": "gemini-3.7-flash-ga-2026-08-13", + "managed_by": "model_registry_release_vontology_service", + "authority_role": "repo_release_input_only", + "model": { + "provider": "gemini", + "model_id": "gemini-3.7-flash", + "concept_id": "#V#google_gemini_3_7_flash", + "registry_entry_id": "#V#gemini_3_7_flash_registry_entry" + }, + "required_existing_concept_ids": [ + "#V#default_model_registry", + "#V#gemini_provider", + "#V#large_language_model", + "#V#model_registry_entry", + "#V#model_api_profile", + "#V#predicate", + "#V#has_model_entry", + "#V#refers_to_model", + "#V#has_provider", + "#V#has_model_api_profile", + "#V#has_model_id", + "#V#has_model_pricing_json", + "#V#has_api_surface", + "#V#has_structured_tool_calling_support", + "#V#has_tool_continuation_mode", + "#V#has_response_storage_policy", + "#V#hasName" + ], + "concepts": [ + { + "concept_id": "#V#has_model_capabilities_json", + "name": "has model capabilities JSON", + "kind": "predicate", + "description": "Stores versioned provider-observed model limits and capability metadata as JSON." + }, + { + "concept_id": "#V#has_provider_connection_id", + "name": "has provider connection ID", + "kind": "predicate", + "description": "Identifies the exact provider connection or runtime lane used by a model API profile." + }, + { + "concept_id": "#V#google_gemini_3_7_flash", + "name": "Google Gemini 3.7 Flash", + "kind": "individual", + "instance_of_type": "#V#large_language_model", + "description": "Google Gemini 3.7 Flash generally available model, stable API identifier gemini-3.7-flash." + }, + { + "concept_id": "#V#gemini_3_7_flash_registry_entry", + "name": "Gemini 3.7 Flash Registry Entry", + "kind": "individual", + "instance_of_type": "#V#model_registry_entry", + "description": "Default Von model-registry entry for the direct Gemini Developer API Gemini 3.7 Flash model." + }, + { + "concept_id": "#V#gemini_3_7_flash_interactions_profile", + "name": "Gemini 3.7 Flash Interactions Profile", + "kind": "individual", + "instance_of_type": "#V#model_api_profile", + "description": "Direct Gemini Interactions API structured transport profile for Gemini 3.7 Flash." + } + ], + "relationships": [ + { + "source_id": "#V#gemini_3_7_flash_registry_entry", + "predicate": "#V#refers_to_model", + "target_id": "#V#google_gemini_3_7_flash" + }, + { + "source_id": "#V#google_gemini_3_7_flash", + "predicate": "#V#has_provider", + "target_id": "#V#gemini_provider" + }, + { + "source_id": "#V#gemini_3_7_flash_registry_entry", + "predicate": "#V#has_model_api_profile", + "target_id": "#V#gemini_3_7_flash_interactions_profile" + }, + { + "source_id": "#V#default_model_registry", + "predicate": "#V#has_model_entry", + "target_id": "#V#gemini_3_7_flash_registry_entry" + } + ], + "text_relations": [ + { + "subject_concept_id": "#V#google_gemini_3_7_flash", + "predicate": "hasName", + "text": "gemini-3.7-flash", + "lang": "en-NZ", + "mode": "additive" + }, + { + "subject_concept_id": "#V#google_gemini_3_7_flash", + "predicate": "hasName", + "text": "gemini:gemini-3.7-flash", + "lang": "en-NZ", + "mode": "additive" + }, + { + "subject_concept_id": "#V#gemini_3_7_flash_registry_entry", + "predicate": "#V#has_model_id", + "text": "gemini-3.7-flash", + "lang": "en-NZ", + "mode": "singleton" + }, + { + "subject_concept_id": "#V#gemini_3_7_flash_registry_entry", + "predicate": "#V#has_model_pricing_json", + "lang": "en-NZ", + "mode": "singleton", + "json": { + "schema_version": "llm_model_pricing.v1", + "version": "gemini-standard-introductory-2026-08-13", + "source": "https://ai.google.dev/gemini-api/docs/pricing", + "effective_at_utc": "2026-08-13T00:00:00Z", + "effective_until_utc": "2027-01-01T00:00:00Z", + "effective_at_basis": "provider_schedule_observed_current", + "provider": "gemini", + "model_id": "gemini-3.7-flash", + "currency": "USD", + "unit_tokens": 1000000, + "applicability": { + "effective_service_tiers": [ + "standard" + ], + "connection_ids": [ + "gemini_developer_api" + ] + }, + "rates": { + "input_tokens": 0.75, + "output_tokens": 3.75 + }, + "caveats": [ + "Estimate only; provider billing is authoritative.", + "These introductory Standard API rates expire after 31 December 2026 and must be replaced before 1 January 2027.", + "Output pricing includes thinking tokens.", + "Context-cache storage, grounding, Batch, Flex and Priority charges are not estimated by this entry." + ] + } + }, + { + "subject_concept_id": "#V#gemini_3_7_flash_registry_entry", + "predicate": "#V#has_model_capabilities_json", + "lang": "en-NZ", + "mode": "singleton", + "json": { + "schema_version": "llm_model_capabilities.v1", + "version": "gemini-3.7-flash-ga-2026-08-13", + "source": "https://ai.google.dev/gemini-api/docs/models/gemini-3.7-flash", + "observed_at_utc": "2026-08-13T00:00:00Z", + "provider": "gemini", + "model_id": "gemini-3.7-flash", + "lifecycle": "generally_available", + "input_token_limit": 1048576, + "output_token_limit": 65536, + "input_modalities": [ + "text", + "image", + "video", + "audio", + "pdf" + ], + "output_modalities": [ + "text" + ], + "features": { + "caching": "supported", + "function_calling": "supported", + "structured_outputs": "supported", + "thinking": { + "supported": true, + "default_level": "medium", + "allowed_levels": [ + "low", + "medium", + "high" + ], + "unsupported_levels": [ + "minimal" + ] + }, + "live_api": "unsupported" + }, + "generation_parameter_policy": { + "omit": [ + "temperature", + "top_p", + "top_k", + "candidate_count", + "thinking_budget" + ], + "use_instead": { + "thinking_budget": "thinking_level" + } + } + } + }, + { + "subject_concept_id": "#V#gemini_3_7_flash_interactions_profile", + "predicate": "#V#has_api_surface", + "text": "interactions", + "lang": "en-NZ", + "mode": "singleton" + }, + { + "subject_concept_id": "#V#gemini_3_7_flash_interactions_profile", + "predicate": "#V#has_structured_tool_calling_support", + "text": "supported", + "lang": "en-NZ", + "mode": "singleton" + }, + { + "subject_concept_id": "#V#gemini_3_7_flash_interactions_profile", + "predicate": "#V#has_tool_continuation_mode", + "text": "stateless", + "lang": "en-NZ", + "mode": "singleton" + }, + { + "subject_concept_id": "#V#gemini_3_7_flash_interactions_profile", + "predicate": "#V#has_response_storage_policy", + "text": "disabled", + "lang": "en-NZ", + "mode": "singleton" + }, + { + "subject_concept_id": "#V#gemini_3_7_flash_interactions_profile", + "predicate": "#V#has_provider_connection_id", + "text": "gemini_developer_api", + "lang": "en-NZ", + "mode": "singleton" + } + ], + "expected_read_back": { + "provider": "gemini", + "model_id": "gemini-3.7-flash", + "registry_entry_id": "#V#gemini_3_7_flash_registry_entry", + "model_concept_id": "#V#google_gemini_3_7_flash", + "api_profile_id": "#V#gemini_3_7_flash_interactions_profile", + "api_surface": "interactions", + "structured_tool_calling": "supported", + "tool_continuation_mode": "stateless", + "response_storage_policy": "disabled", + "connection_id": "gemini_developer_api", + "pricing_version": "gemini-standard-introductory-2026-08-13", + "capabilities_version": "gemini-3.7-flash-ga-2026-08-13" + } +} diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index 4ae4d7d2f..a46ab2335 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -1,6 +1,10 @@ // Chat Tab Module import { annotateTurn, fetchWithTimeout, getJsonDetailed, getUserContext, getWindowSessionId, postJson, WINDOW_SESSION_HEADER } from './apiService.js'; -import { getEffectiveLocalModelPreference, resolveLocalRequestedLlm } from './utils/localModelPreferences.js'; +import { + buildLocalModelRequestFields, + getEffectiveLocalModelPreference, + resolveLocalRequestedLlm, +} from './utils/localModelPreferences.js'; import { initializeConceptAutocomplete } from './components/conceptAutocomplete.js'; import { initializeMessagePanel, loadUnreadCount } from './components/messagePanel.js'; import { loadMyOrganisations } from './components/orgSelector.js'; @@ -33719,6 +33723,7 @@ async function handleSendPrompt(options = {}) { // Get user context from localStorage to send to backend const userContext = getUserContext(); const localRequestedLlm = resolveLocalRequestedLlm(); + const localModelRequestFields = buildLocalModelRequestFields(localRequestedLlm); // Presenter-mode controls whether the backend produces two-channel output // (screen + spoken). This should be enabled regardless of whether auto-TTS // is enabled, so clicking Speak later never needs to read raw markdown. @@ -33739,8 +33744,7 @@ async function handleSendPrompt(options = {}) { user_id: userContext.user_id, org_id: userContext.org_id, language: userContext.language, - ...(localRequestedLlm?.requestModel ? { model: localRequestedLlm.requestModel } : {}), - ...(localRequestedLlm?.model_parameters ? { model_parameters: localRequestedLlm.model_parameters } : {}), + ...localModelRequestFields, ...(request.pendingFileCopyConceptId ? { workflow_inputs: { file_copy_concept_id: request.pendingFileCopyConceptId diff --git a/src/frontend/web/von_interface/static/js/domUtils.js b/src/frontend/web/von_interface/static/js/domUtils.js index deb6f0434..5d9ddac9a 100644 --- a/src/frontend/web/von_interface/static/js/domUtils.js +++ b/src/frontend/web/von_interface/static/js/domUtils.js @@ -1267,6 +1267,7 @@ export async function setModelInfoFooterText() { // Pass the effective provider so the status check reflects the user's local model // preference rather than always checking the DB-stored (e.g. OpenAI) provider. if (effectiveLlm?.provider) llmParams.set('effective_provider', effectiveLlm.provider); + if (effectiveLlm?.model) llmParams.set('effective_model', effectiveLlm.model); const llmUrl = '/api/settings/llm/info' + (llmParams.toString() ? '?' + llmParams.toString() : ''); llmInfo = await fetchJsonWithTimeout(llmUrl, { cache: 'no-store', diff --git a/src/frontend/web/von_interface/static/js/settings.js b/src/frontend/web/von_interface/static/js/settings.js index 840820bc8..f06176cb6 100644 --- a/src/frontend/web/von_interface/static/js/settings.js +++ b/src/frontend/web/von_interface/static/js/settings.js @@ -86,10 +86,20 @@ export async function loadAvailableOpenAIModels() { return data; } -function renderOpenAiModelSelect(select, models = [], selectedModel = null) { +export async function loadAvailableGeminiModels() { + const { data } = await getJsonDetailed('/api/settings/models/gemini'); + return data; +} + +function premiumProviderLabel(provider) { + return provider === 'gemini' ? 'Gemini' : 'OpenAI'; +} + +function renderPremiumModelSelect(select, provider, models = [], selectedModel = null) { if (!select) return; - select.innerHTML = ''; + const providerLabel = premiumProviderLabel(provider); + select.innerHTML = ``; if (Array.isArray(models) && models.length > 0) { models.forEach(rawModelName => { @@ -102,19 +112,19 @@ function renderOpenAiModelSelect(select, models = [], selectedModel = null) { select.appendChild(option); }); } else { - select.innerHTML = ''; + select.innerHTML = ``; } - const selectedOpenAiModel = normaliseLocalModelName(selectedModel); - if (selectedOpenAiModel) { - select.value = selectedOpenAiModel; - if (select.value !== selectedOpenAiModel) { + const selectedPremiumModel = normaliseLocalModelName(selectedModel); + if (selectedPremiumModel) { + select.value = selectedPremiumModel; + if (select.value !== selectedPremiumModel) { const currentOption = document.createElement('option'); - currentOption.value = selectedOpenAiModel; - currentOption.textContent = `${selectedOpenAiModel} (current effective model; unavailable in loaded list)`; + currentOption.value = selectedPremiumModel; + currentOption.textContent = `${selectedPremiumModel} (current effective model; unavailable in loaded list)`; currentOption.dataset.currentEffective = 'true'; select.appendChild(currentOption); - select.value = selectedOpenAiModel; + select.value = selectedPremiumModel; } } } @@ -122,7 +132,13 @@ function renderOpenAiModelSelect(select, models = [], selectedModel = null) { export function renderOpenAIModelOptions(selectElementId, models = [], selectedModel = null) { const select = document.getElementById(selectElementId); if (!select) return; - renderOpenAiModelSelect(select, models, selectedModel); + renderPremiumModelSelect(select, 'openai', models, selectedModel); +} + +export function renderGeminiModelOptions(selectElementId, models = [], selectedModel = null) { + const select = document.getElementById(selectElementId); + if (!select) return; + renderPremiumModelSelect(select, 'gemini', models, selectedModel); } export async function loadAvailablePeople() { @@ -295,7 +311,7 @@ export async function populateOpenAIModelDropdown(selectElementId, selectedModel try { const models = await loadAvailableOpenAIModels(); - renderOpenAiModelSelect(select, models, selectedModel); + renderPremiumModelSelect(select, 'openai', models, selectedModel); clearSelectRetryState(select); } catch (err) { console.error('Error populating OpenAI model dropdown:', err); @@ -307,6 +323,24 @@ export async function populateOpenAIModelDropdown(selectElementId, selectedModel } } +export async function populateGeminiModelDropdown(selectElementId, selectedModel = null) { + const select = document.getElementById(selectElementId); + if (!select) return; + + try { + const models = await loadAvailableGeminiModels(); + renderPremiumModelSelect(select, 'gemini', models, selectedModel); + clearSelectRetryState(select); + } catch (err) { + console.error('Error populating Gemini model dropdown:', err); + renderRetryableSelectFailure(select, { + error: err, + fallbackMessage: 'Gemini models are temporarily unavailable.', + retryAction: () => populateGeminiModelDropdown(selectElementId, selectedModel) + }); + } +} + export async function populatePeopleDropdown(selectElementId, selectedPersonId = null) { const select = document.getElementById(selectElementId); if (!select) return; diff --git a/src/frontend/web/von_interface/static/js/settingsPage.js b/src/frontend/web/von_interface/static/js/settingsPage.js index 13a802b39..5c64147fe 100644 --- a/src/frontend/web/von_interface/static/js/settingsPage.js +++ b/src/frontend/web/von_interface/static/js/settingsPage.js @@ -16,7 +16,9 @@ import { loadAndRenderOllamaHosts, loadOllamaHosts, populateModelDropdown, + populateGeminiModelDropdown, populateOpenAIModelDropdown, + renderGeminiModelOptions, renderOpenAIModelOptions, populateOrganisationsDropdown, populatePeopleDropdown, @@ -55,15 +57,21 @@ import { } from './utils/runtimeIdentityBootstrap.js'; import { clearStoredOllamaSelection, + getStoredGeminiModelParameters, + getStoredGeminiSelectedModel, + getStoredPremiumModelProvider, getEffectiveLocalModelPreference, getStoredOpenAiModelParameters, getStoredOpenAiSelectedModel, normaliseModelParameters, normaliseLocalModelName, setLocalPremiumModelUseEnabled, + setStoredGeminiModelParameters, + setStoredGeminiSelectedModel, setStoredOllamaSelection, setStoredOpenAiModelParameters, setStoredOpenAiSelectedModel, + setStoredPremiumModelProvider, } from './utils/localModelPreferences.js'; import { __testOnly_resetWorkflowCapabilityIndexStatusCoordinator, @@ -137,6 +145,8 @@ let currentEffectiveLlm = null; let currentEffectiveEnabledLlms = []; let latestOpenAiModelProbe = null; let latestOpenAiModelParameterCapability = null; +let latestGeminiModelProbe = null; +let latestGeminiModelParameterCapability = null; let openAiModelCostSummaryGeneration = 0; let latestOllamaModelProbe = null; let latestSettingsAuthStatus = null; @@ -245,7 +255,7 @@ const SETTINGS_ANCHOR_LABELS = Object.freeze({ 'current-organisation-settings': 'Organisation', 'conversation-history-settings': 'History', 'speech-settings': 'Speech', - 'premium-model-settings': 'OpenAI', + 'premium-model-settings': 'Premium', 'ollima-settings': 'Ollama', 'rag-model-settings': 'RAG & index', 'vontology-performance': 'Vontology UI', @@ -323,24 +333,79 @@ function getSettingsConcernIdentitySnapshot() { }; } +const PREMIUM_PROVIDER_CONFIG = Object.freeze({ + openai: Object.freeze({ + label: 'OpenAI', + envInputId: 'openaiApiKeyEnvVar', + verifyButtonId: 'verifyOpenAiApiKeyButton', + providerContainerId: 'openaiProviderSettings', + modelsContainerId: 'openaiModelsContainer', + modelSelectId: 'openaiModelSelect', + testButtonId: 'testOpenAiModelButton', + addButtonId: 'addOpenAiToWorkflowPoolButton', + eligibilityStatusId: 'openaiModelEligibilityStatus', + modelStatusId: 'openaiModelStatusMessage', + providerStatusId: 'openaiStatusMessage', + verifyEndpoint: '/api/settings/openai/verify', + testEndpoint: '/api/settings/openai/test_model', + apiSurface: 'responses', + }), + gemini: Object.freeze({ + label: 'Gemini', + envInputId: 'geminiApiKeyEnvVar', + verifyButtonId: 'verifyGeminiApiKeyButton', + providerContainerId: 'geminiProviderSettings', + modelsContainerId: 'geminiModelsContainer', + modelSelectId: 'geminiModelSelect', + testButtonId: 'testGeminiModelButton', + addButtonId: 'addGeminiToWorkflowPoolButton', + eligibilityStatusId: 'geminiModelEligibilityStatus', + modelStatusId: 'geminiModelStatusMessage', + providerStatusId: 'geminiStatusMessage', + verifyEndpoint: '/api/settings/gemini/verify', + testEndpoint: '/api/settings/gemini/test_model', + apiSurface: 'interactions', + }), +}); + +function normalisePremiumProvider(value) { + if (typeof value !== 'string') return null; + const provider = value.trim().toLowerCase(); + return Object.prototype.hasOwnProperty.call(PREMIUM_PROVIDER_CONFIG, provider) + ? provider + : null; +} + +function selectedPremiumProvider() { + return normalisePremiumProvider(document.getElementById('premiumProviderSelect')?.value) + || normalisePremiumProvider(getStoredPremiumModelProvider()) + || 'openai'; +} + +function selectedPremiumModelName(provider = selectedPremiumProvider()) { + const config = PREMIUM_PROVIDER_CONFIG[provider]; + if (!config) return ''; + const storedModel = provider === 'gemini' + ? getStoredGeminiSelectedModel() + : getStoredOpenAiSelectedModel(); + return String(document.getElementById(config.modelSelectId)?.value || storedModel || '').trim(); +} + function getSettingsConcernModelSnapshot() { const localModelPreference = getEffectiveLocalModelPreference(); const premiumToggle = document.getElementById('enableOpenAiPremiumToggle'); const premiumEnabled = premiumToggle ? !!premiumToggle.checked - : localModelPreference.activeSource === 'openai'; - const openAiModel = String( - document.getElementById('openaiModelSelect')?.value - || getStoredOpenAiSelectedModel() - || localModelPreference.openaiModel - || '', - ).trim(); + : ['openai', 'gemini'].includes(localModelPreference.activeSource); + const premiumProvider = selectedPremiumProvider(); + const premiumModel = selectedPremiumModelName(premiumProvider); const ollamaSelection = resolveOllamaSelection(false) || localModelPreference.ollamaSelection || null; const ollamaModel = String(ollamaSelection?.model || ollamaSelection?.value || '').trim(); return { premiumEnabled, - openAiModel, + premiumProvider, + premiumModel, ollamaModel, }; } @@ -395,7 +460,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, #openaiModelSelect, #openaiApiKeyEnvVar', + focusSelector: '#globalModelSelect, #premiumProviderSelect, #openaiModelSelect, #geminiModelSelect', source: 'temporary-placeholder', }; } @@ -410,16 +475,22 @@ function buildTemporarySettingsConcernRecommendation(concernId) { source: 'temporary-placeholder', }; case 'models': { - const { premiumEnabled, openAiModel, ollamaModel } = getSettingsConcernModelSnapshot(); - - if (premiumEnabled && !openAiModel) { + const { + premiumEnabled, + premiumProvider, + premiumModel, + ollamaModel, + } = getSettingsConcernModelSnapshot(); + const providerLabel = PREMIUM_PROVIDER_CONFIG[premiumProvider]?.label || 'premium'; + + if (premiumEnabled && !premiumModel) { return { kicker: 'Suggested next step', title: 'Choose the premium model before relying on it', - description: 'Premium use is enabled for this browser, but there is no selected OpenAI model yet. Pick one before you leave this page.', + description: `Premium use is enabled for this browser, but there is no selected ${providerLabel} model yet. Pick one before you leave this page.`, actionLabel: 'Choose premium model', actionTarget: 'premium-model-settings', - focusSelector: '#openaiApiKeyEnvVar, #openaiModelSelect', + focusSelector: `#${PREMIUM_PROVIDER_CONFIG[premiumProvider]?.envInputId}, #${PREMIUM_PROVIDER_CONFIG[premiumProvider]?.modelSelectId}`, source: 'temporary-placeholder', }; } @@ -436,14 +507,14 @@ function buildTemporarySettingsConcernRecommendation(concernId) { }; } - if (premiumEnabled && openAiModel) { + if (premiumEnabled && premiumModel) { return { kicker: 'Suggested next step', title: 'Test the selected premium model before relying on it', - description: `${openAiModel} is selected for premium use on this browser. Run a quick check here before you return to chat.`, + description: `${providerLabel} ${premiumModel} is selected for premium use on this browser. Run a quick check here before you return to chat.`, actionLabel: 'Review premium model', actionTarget: 'premium-model-settings', - focusSelector: '#testOpenAiModelButton, #openaiModelSelect', + focusSelector: `#${PREMIUM_PROVIDER_CONFIG[premiumProvider]?.testButtonId}, #${PREMIUM_PROVIDER_CONFIG[premiumProvider]?.modelSelectId}`, source: 'temporary-placeholder', }; } @@ -854,6 +925,7 @@ function resolveDisplayedProviderModels(settings) { const enabledLlms = Array.isArray(settings?.enabled_llms) ? settings.enabled_llms : []; let currentOllamaModel = null; let currentOpenAIModel = null; + let currentGeminiModel = null; for (const entry of enabledLlms) { if (!entry || typeof entry !== 'object') continue; @@ -861,6 +933,8 @@ function resolveDisplayedProviderModels(settings) { currentOllamaModel = entry.model; } else if (entry.provider === 'openai' && entry.model && !currentOpenAIModel) { currentOpenAIModel = entry.model; + } else if (entry.provider === 'gemini' && entry.model && !currentGeminiModel) { + currentGeminiModel = entry.model; } } @@ -868,12 +942,15 @@ function resolveDisplayedProviderModels(settings) { currentOllamaModel = effectiveLlm.model; } else if (effectiveLlm?.provider === 'openai' && effectiveLlm.model) { currentOpenAIModel = effectiveLlm.model; + } else if (effectiveLlm?.provider === 'gemini' && effectiveLlm.model) { + currentGeminiModel = effectiveLlm.model; } return { effectiveLlm, currentOllamaModel, currentOpenAIModel, + currentGeminiModel, }; } @@ -1063,15 +1140,43 @@ function setInlineStatusMessage(element, text, tone = null) { element.style.display = element.textContent ? 'block' : 'none'; } -function readOpenAiModelParametersFromUi() { +function getStoredPremiumModelParameters(provider) { + return provider === 'gemini' + ? getStoredGeminiModelParameters() + : getStoredOpenAiModelParameters(); +} + +function setStoredPremiumModelParameters(provider, parameters) { + if (provider === 'gemini') { + setStoredGeminiModelParameters(parameters); + } else { + setStoredOpenAiModelParameters(parameters); + } +} + +function getLatestPremiumModelParameterCapability(provider) { + return provider === 'gemini' + ? latestGeminiModelParameterCapability + : latestOpenAiModelParameterCapability; +} + +function setLatestPremiumModelParameterCapability(provider, capability) { + if (provider === 'gemini') { + latestGeminiModelParameterCapability = capability; + } else { + latestOpenAiModelParameterCapability = capability; + } +} + +function readPremiumModelParametersFromUi(provider = selectedPremiumProvider()) { const effort = String(document.getElementById('openaiReasoningEffortSelect')?.value || '').trim(); return normaliseModelParameters( effort ? { reasoning_effort: effort } : null, - latestOpenAiModelParameterCapability, + getLatestPremiumModelParameterCapability(provider), ); } -function applyOpenAiReasoningEffortControls(capability) { +function applyPremiumReasoningEffortControls(provider, capability) { const container = document.getElementById('openaiReasoningEffortContainer'); const selectEl = document.getElementById('openaiReasoningEffortSelect'); if (!container || !selectEl) return; @@ -1082,7 +1187,7 @@ function applyOpenAiReasoningEffortControls(capability) { selectEl.disabled = !supported; if (!supported) { selectEl.value = ''; - setStoredOpenAiModelParameters(null); + setStoredPremiumModelParameters(provider, null); return; } @@ -1091,7 +1196,7 @@ function applyOpenAiReasoningEffortControls(capability) { .map((value) => String(value || '').trim().toLowerCase()) .filter(Boolean) : []; - const current = getStoredOpenAiModelParameters()?.reasoning_effort || ''; + const current = getStoredPremiumModelParameters(provider)?.reasoning_effort || ''; selectEl.replaceChildren(); const defaultOption = document.createElement('option'); defaultOption.value = ''; @@ -1106,54 +1211,78 @@ function applyOpenAiReasoningEffortControls(capability) { selectEl.append(option); } selectEl.value = current && allowedValues.includes(current) ? current : ''; - setStoredOpenAiModelParameters(readOpenAiModelParametersFromUi()); + setStoredPremiumModelParameters(provider, readPremiumModelParametersFromUi(provider)); } -async function refreshOpenAiReasoningEffortControls() { - const selectedModel = String(document.getElementById('openaiModelSelect')?.value || '').trim(); +async function refreshPremiumReasoningEffortControls(provider = selectedPremiumProvider()) { + const config = PREMIUM_PROVIDER_CONFIG[provider]; + const selectedModel = selectedPremiumModelName(provider); if (!selectedModel) { - latestOpenAiModelParameterCapability = null; - applyOpenAiReasoningEffortControls({ parameters: { reasoning_effort: { supported: false } } }); + setLatestPremiumModelParameterCapability(provider, null); + applyPremiumReasoningEffortControls( + provider, + { parameters: { reasoning_effort: { supported: false } } }, + ); return null; } try { const response = await fetch( - `/api/settings/model_parameters/capabilities?provider=openai&model=${encodeURIComponent(selectedModel)}&api_surface=responses&include_registry=true`, + `/api/settings/model_parameters/capabilities?provider=${encodeURIComponent(provider)}&model=${encodeURIComponent(selectedModel)}&api_surface=${encodeURIComponent(config.apiSurface)}&include_registry=true`, { cache: 'no-store' }, ); const capability = await response.json(); if (response.ok && capability?.success !== false) { - latestOpenAiModelParameterCapability = capability; - applyOpenAiReasoningEffortControls(capability); + setLatestPremiumModelParameterCapability(provider, capability); + applyPremiumReasoningEffortControls(provider, capability); return capability; } } catch (error) { - console.warn('Failed to refresh OpenAI model parameter controls', error); + console.warn(`Failed to refresh ${config.label} model parameter controls`, error); } - latestOpenAiModelParameterCapability = null; - applyOpenAiReasoningEffortControls({ parameters: { reasoning_effort: { supported: false } } }); + setLatestPremiumModelParameterCapability(provider, null); + applyPremiumReasoningEffortControls( + provider, + { parameters: { reasoning_effort: { supported: false } } }, + ); return null; } -function updateOpenAiModelStatusMessage() { - const statusEl = document.getElementById('openaiModelStatusMessage'); - const toggleEl = document.getElementById('enableOpenAiPremiumToggle'); - const selectEl = document.getElementById('openaiModelSelect'); +async function refreshOpenAiReasoningEffortControls() { + return refreshPremiumReasoningEffortControls('openai'); +} + +function latestPremiumModelProbe(provider) { + return provider === 'gemini' ? latestGeminiModelProbe : latestOpenAiModelProbe; +} + +function setLatestPremiumModelProbe(provider, probe) { + if (provider === 'gemini') { + latestGeminiModelProbe = probe; + } else { + latestOpenAiModelProbe = probe; + } +} + +function updatePremiumModelStatusMessageForProvider(provider) { + const config = PREMIUM_PROVIDER_CONFIG[provider]; + const statusEl = document.getElementById(config?.modelStatusId); if (!statusEl) return; - const allowed = updateSelectedOpenAiEligibilityControls(); - const premiumEnabled = !!toggleEl?.checked; - const selectedModel = String(selectEl?.value || getStoredOpenAiSelectedModel() || '').trim(); - const selectedModelParameters = readOpenAiModelParametersFromUi() - || getStoredOpenAiModelParameters() - || null; + const premiumEnabled = !!document.getElementById('enableOpenAiPremiumToggle')?.checked + && selectedPremiumProvider() === provider; + const selectedModel = selectedPremiumModelName(provider); + const selectedModelParameters = provider === selectedPremiumProvider() + ? (readPremiumModelParametersFromUi(provider) || getStoredPremiumModelParameters(provider)) + : getStoredPremiumModelParameters(provider); + const allowed = isPersistedEffectiveModelAllowed(selectedPremiumPoolEntry(provider)); + const probe = latestPremiumModelProbe(provider); if (!selectedModel) { setInlineStatusMessage( statusEl, premiumEnabled - ? 'Select a premium model, then test it before relying on it.' - : 'Premium use is disabled on this machine. Ollama remains the active provider here.', + ? `Select a ${config.label} model, then test it before relying on it.` + : `Select a ${config.label} model to configure this provider.`, null, ); return; @@ -1162,37 +1291,35 @@ function updateOpenAiModelStatusMessage() { if (!allowed) { setInlineStatusMessage( statusEl, - `Testing and browser use remain unavailable until ${selectedModel} is allowed for this scope and saved.`, + `Testing and browser use remain unavailable until ${config.label} ${selectedModel} is allowed for this scope and saved.`, 'error', ); return; } if ( - latestOpenAiModelProbe - && latestOpenAiModelProbe.model === selectedModel - && stableModelParametersKey(latestOpenAiModelProbe.model_parameters) + probe + && probe.model === selectedModel + && stableModelParametersKey(probe.model_parameters) === stableModelParametersKey(selectedModelParameters) ) { - if (latestOpenAiModelProbe.usable) { + if (probe.usable) { const prefix = premiumEnabled - ? `${selectedModel} is usable.` - : `${selectedModel} tested successfully, but premium use is disabled.`; + ? `${config.label} ${selectedModel} is usable.` + : `${config.label} ${selectedModel} tested successfully but is not this browser's active provider.`; setInlineStatusMessage( statusEl, - latestOpenAiModelProbe.reason ? `${prefix} ${latestOpenAiModelProbe.reason}` : prefix, + probe.reason ? `${prefix} ${probe.reason}` : prefix, premiumEnabled ? 'success' : null, ); return; } const failurePrefix = premiumEnabled - ? `${selectedModel} is not usable.` - : `Premium use is disabled on this machine. ${selectedModel} also failed its last test.`; + ? `${config.label} ${selectedModel} is not usable.` + : `${config.label} ${selectedModel} failed its last test.`; setInlineStatusMessage( statusEl, - latestOpenAiModelProbe.reason - ? `${failurePrefix} ${latestOpenAiModelProbe.reason}` - : failurePrefix, + probe.reason ? `${failurePrefix} ${probe.reason}` : failurePrefix, 'error', ); return; @@ -1201,12 +1328,22 @@ function updateOpenAiModelStatusMessage() { setInlineStatusMessage( statusEl, premiumEnabled - ? `Selected premium model has not been tested yet.` - : `Premium use is disabled on this machine. You can still test ${selectedModel} before enabling it.`, + ? `Selected ${config.label} model has not been tested yet.` + : `${config.label} ${selectedModel} is configured but is not this browser's active premium model.`, null, ); } +function updatePremiumModelStatusMessages() { + updateSelectedPremiumEligibilityControls(); + updatePremiumModelStatusMessageForProvider('openai'); + updatePremiumModelStatusMessageForProvider('gemini'); +} + +function updateOpenAiModelStatusMessage() { + updatePremiumModelStatusMessages(); +} + function updateOllamaModelStatusMessage() { const statusEl = document.getElementById('ollamaModelStatusMessage'); if (!statusEl) return; @@ -1266,46 +1403,119 @@ function notifyLocalModelPreferenceChanged() { } } -async function testSelectedOpenAiModel() { - const apiKeyEnvVar = document.getElementById('openaiApiKeyEnvVar')?.value; - const selectedModel = String(document.getElementById('openaiModelSelect')?.value || '').trim(); - const modelParameters = readOpenAiModelParametersFromUi(); - const statusEl = document.getElementById('openaiModelStatusMessage'); +function syncPremiumProviderPanels() { + const provider = selectedPremiumProvider(); + for (const [candidateProvider, config] of Object.entries(PREMIUM_PROVIDER_CONFIG)) { + const container = document.getElementById(config.providerContainerId); + if (!container) continue; + const visible = candidateProvider === provider; + container.classList.toggle('hidden', !visible); + container.setAttribute('aria-hidden', String(!visible)); + } + const label = document.getElementById('enablePremiumModelLabel'); + if (label) { + label.textContent = `Use selected allowed ${PREMIUM_PROVIDER_CONFIG[provider].label} model for chat in this browser`; + } + return provider; +} + +async function handlePremiumModelSelectionChange(provider) { + const config = PREMIUM_PROVIDER_CONFIG[provider]; + const model = normaliseLocalModelName( + document.getElementById(config?.modelSelectId)?.value, + ) || ''; + if (provider === 'gemini') { + setStoredGeminiSelectedModel(model); + } else { + setStoredOpenAiSelectedModel(model); + } + invalidateSelectedOpenAiCostSummary(); + if (selectedPremiumProvider() === provider) { + await refreshPremiumReasoningEffortControls(provider); + } + setLatestPremiumModelProbe(provider, null); + updatePremiumModelStatusMessages(); + refreshActiveSettingsConcernGuidance(); + notifyLocalModelPreferenceChanged(); + renderModelScopeOverview(); + void refreshSelectedOpenAiCostSummary(); +} + +async function handlePremiumProviderSelectionChange(value) { + const provider = normalisePremiumProvider(value) || selectedPremiumProvider(); + const select = document.getElementById('premiumProviderSelect'); + if (select) select.value = provider; + setStoredPremiumModelProvider(provider); + syncPremiumProviderPanels(); + await refreshPremiumReasoningEffortControls(provider); + + const toggle = document.getElementById('enableOpenAiPremiumToggle'); + if (toggle?.checked) { + const allowed = isPersistedEffectiveModelAllowed(selectedPremiumPoolEntry(provider)); + toggle.checked = allowed; + setLocalPremiumModelUseEnabled(allowed, provider); + } + updatePremiumModelStatusMessages(); + updateOllamaModelStatusMessage(); + invalidateSelectedOpenAiCostSummary(); + void refreshSelectedOpenAiCostSummary(); + refreshActiveSettingsConcernGuidance(); + notifyLocalModelPreferenceChanged(); + renderModelScopeOverview(); + + if (await checkPremiumProviderEnvVar(provider)) { + await verifyPremiumApiKey(provider); + } +} + +async function testSelectedPremiumModel(provider) { + const config = PREMIUM_PROVIDER_CONFIG[provider]; + const apiKeyEnvVar = document.getElementById(config.envInputId)?.value; + const selectedModel = selectedPremiumModelName(provider); + const modelParameters = provider === selectedPremiumProvider() + ? readPremiumModelParametersFromUi(provider) + : getStoredPremiumModelParameters(provider); + const statusEl = document.getElementById(config.modelStatusId); if (!selectedModel) { - latestOpenAiModelProbe = null; - updateOpenAiModelStatusMessage(); + setLatestPremiumModelProbe(provider, null); + updatePremiumModelStatusMessages(); return { usable: false, model: null, reason: 'No premium model selected.' }; } - if (!isPersistedEffectiveModelAllowed(selectedOpenAiPoolEntry())) { - latestOpenAiModelProbe = null; - updateOpenAiModelStatusMessage(); + if (!isPersistedEffectiveModelAllowed(selectedPremiumPoolEntry(provider))) { + setLatestPremiumModelProbe(provider, null); + updatePremiumModelStatusMessages(); return { usable: false, model: selectedModel, - reason: 'The exact premium model is not allowed for the selected scope.', + reason: 'The exact provider and premium model are not allowed for the selected scope.', failure_kind: 'premium_model_not_allowed', }; } - setStoredOpenAiSelectedModel(selectedModel); - setStoredOpenAiModelParameters(modelParameters); - setInlineStatusMessage(statusEl, `Testing ${selectedModel}...`, null); + if (provider === 'gemini') { + setStoredGeminiSelectedModel(selectedModel); + } else { + setStoredOpenAiSelectedModel(selectedModel); + } + setStoredPremiumModelParameters(provider, modelParameters); + setInlineStatusMessage(statusEl, `Testing ${config.label} ${selectedModel}...`, null); try { - const response = await postJson('/api/settings/openai/test_model', { + const response = await postJson(config.testEndpoint, { api_key_env_var: apiKeyEnvVar, model: selectedModel, ...(modelParameters ? { model_parameters: modelParameters } : {}), }); - latestOpenAiModelProbe = { + setLatestPremiumModelProbe(provider, { usable: !!response?.usable, model: String(response?.model || selectedModel), model_parameters: normaliseModelParameters(response?.model_parameters) || modelParameters, reason: String(response?.reason || '').trim(), failure_kind: String(response?.failure_kind || '').trim() || null, - }; + api_surface: String(response?.api_surface || '').trim() || null, + }); } catch (error) { const message = String(error?.message || '').trim(); const restartHint = ( @@ -1313,19 +1523,27 @@ async function testSelectedOpenAiModel() { || /unexpected token { showStatusMessage('settingsStatusMessage', 'Error saving timeout.', true); } }); - document.getElementById('openaiModelSelect')?.addEventListener('change', async () => { - const selectedModel = document.getElementById('openaiModelSelect')?.value || ''; - setStoredOpenAiSelectedModel(selectedModel); - invalidateSelectedOpenAiCostSummary(); - await refreshOpenAiReasoningEffortControls(); - latestOpenAiModelProbe = null; - updateOpenAiModelStatusMessage(); - refreshActiveSettingsConcernGuidance(); - notifyLocalModelPreferenceChanged(); - renderModelScopeOverview(); - void refreshSelectedOpenAiCostSummary(); + document.getElementById('premiumProviderSelect')?.addEventListener('change', (event) => { + void handlePremiumProviderSelectionChange(event?.target?.value); + }); + document.getElementById('openaiModelSelect')?.addEventListener('change', () => { + void handlePremiumModelSelectionChange('openai'); + }); + document.getElementById('geminiModelSelect')?.addEventListener('change', () => { + void handlePremiumModelSelectionChange('gemini'); }); document.getElementById('openaiReasoningEffortSelect')?.addEventListener('change', async () => { - setStoredOpenAiModelParameters(readOpenAiModelParametersFromUi()); - latestOpenAiModelProbe = null; - updateOpenAiModelStatusMessage(); + const provider = selectedPremiumProvider(); + setStoredPremiumModelParameters(provider, readPremiumModelParametersFromUi(provider)); + setLatestPremiumModelProbe(provider, null); + updatePremiumModelStatusMessages(); refreshActiveSettingsConcernGuidance(); notifyLocalModelPreferenceChanged(); renderModelScopeOverview(); @@ -4673,18 +4926,19 @@ document.addEventListener('DOMContentLoaded', async () => { return; } - if (!isPersistedEffectiveModelAllowed(selectedOpenAiPoolEntry())) { + const provider = selectedPremiumProvider(); + if (!isPersistedEffectiveModelAllowed(selectedPremiumPoolEntry(provider))) { premiumToggle.checked = false; setLocalPremiumModelUseEnabled(false); - updateOpenAiModelStatusMessage(); + updatePremiumModelStatusMessages(); refreshActiveSettingsConcernGuidance(); notifyLocalModelPreferenceChanged(); renderModelScopeOverview(); return; } - setLocalPremiumModelUseEnabled(true); - updateOpenAiModelStatusMessage(); + setLocalPremiumModelUseEnabled(true, provider); + updatePremiumModelStatusMessages(); refreshActiveSettingsConcernGuidance(); notifyLocalModelPreferenceChanged(); renderModelScopeOverview(); @@ -4785,11 +5039,18 @@ document.getElementById('refreshOllamaModelsButton')?.addEventListener('click', document.getElementById('verifyOpenAiApiKeyButton')?.addEventListener('click', () => { verifyOpenAiApiKey(); }); +document.getElementById('verifyGeminiApiKeyButton')?.addEventListener('click', () => { + verifyGeminiApiKey(); +}); document.getElementById('testOpenAiModelButton')?.addEventListener('click', testSelectedOpenAiModel); +document.getElementById('testGeminiModelButton')?.addEventListener('click', testSelectedGeminiModel); document.getElementById('testOllamaModelButton')?.addEventListener('click', testSelectedOllamaModel); document.getElementById('addOpenAiToWorkflowPoolButton')?.addEventListener('click', () => { addOrUpdateWorkflowPoolEntry(selectedOpenAiPoolEntry()); }); +document.getElementById('addGeminiToWorkflowPoolButton')?.addEventListener('click', () => { + addOrUpdateWorkflowPoolEntry(selectedGeminiPoolEntry()); +}); document.getElementById('addOllamaToWorkflowPoolButton')?.addEventListener('click', () => { addOrUpdateWorkflowPoolEntry(selectedOllamaPoolEntry()); }); @@ -5106,7 +5367,11 @@ async function loadAndDisplaySettings() { // The selector should reflect the resolved active model for the provider in scope, // not a stale enabled_llms entry from an older or broader context. - const { currentOpenAIModel } = resolveDisplayedProviderModels(settings); + const { + effectiveLlm: displayedEffectiveLlm, + currentOpenAIModel, + currentGeminiModel, + } = resolveDisplayedProviderModels(settings); currentEffectiveLlm = settings.effective_llm || settings.resolved_llm || null; currentEffectiveEnabledLlms = normaliseEnabledLlmEntries( settings.effective_enabled_llms || settings.enabled_llms, @@ -5116,7 +5381,16 @@ async function loadAndDisplaySettings() { const localModelPreference = getEffectiveLocalModelPreference(); const currentOllamaModel = resolveOllamaDropdownSelectionValue(localModelPreference); const preferredOpenAiModel = currentOpenAIModel || localModelPreference.openaiModel || null; - const openAiPremiumEnabled = localModelPreference.activeSource === 'openai'; + const preferredGeminiModel = currentGeminiModel || localModelPreference.geminiModel || null; + const premiumEnabled = ['openai', 'gemini'].includes(localModelPreference.activeSource); + const preferredPremiumProvider = normalisePremiumProvider( + localModelPreference.premiumProvider || localModelPreference.activeSource, + ) + || normalisePremiumProvider(displayedEffectiveLlm?.provider) + || (currentGeminiModel && !currentOpenAIModel ? 'gemini' : 'openai'); + const premiumProviderSelect = document.getElementById('premiumProviderSelect'); + if (premiumProviderSelect) premiumProviderSelect.value = preferredPremiumProvider; + syncPremiumProviderPanels(); // Load and display Ollama hosts first await loadAndRenderOllamaHosts(); @@ -5133,8 +5407,8 @@ async function loadAndDisplaySettings() { } } catch (_e) { /* non-critical */ } - // Try to populate OpenAI models directly (without verification, if API key is already configured) - if (preferredOpenAiModel) { + // Populate the selected premium provider directly when a model is already known. + if (preferredPremiumProvider === 'openai' && preferredOpenAiModel) { try { await populateOpenAIModelDropdown('openaiModelSelect', preferredOpenAiModel); const oac = document.getElementById('openaiModelsContainer'); @@ -5143,6 +5417,15 @@ async function loadAndDisplaySettings() { } catch (error) { console.log('Could not directly load OpenAI models, will need verification:', error.message); } + } else if (preferredPremiumProvider === 'gemini' && preferredGeminiModel) { + try { + await populateGeminiModelDropdown('geminiModelSelect', preferredGeminiModel); + const container = document.getElementById('geminiModelsContainer'); + if (container) { container.style.display = 'block'; container.classList.remove('hidden'); } + await refreshPremiumReasoningEffortControls('gemini'); + } catch (error) { + console.log('Could not directly load Gemini models, will need verification:', error.message); + } } // Populate People and select the saved one @@ -5283,19 +5566,28 @@ async function loadAndDisplaySettings() { suppressActorOrgSwitchReload = false; } - // Populate OpenAI settings + // Populate premium-provider settings const envVarInput = document.getElementById('openaiApiKeyEnvVar'); if (envVarInput && settings.openai_api_key_env_var) { envVarInput.value = settings.openai_api_key_env_var; } + const geminiEnvVarInput = document.getElementById('geminiApiKeyEnvVar'); + if (geminiEnvVarInput && settings.gemini_api_key_env_var) { + geminiEnvVarInput.value = settings.gemini_api_key_env_var; + } const openAiPremiumToggle = document.getElementById('enableOpenAiPremiumToggle'); if (openAiPremiumToggle) { - openAiPremiumToggle.checked = openAiPremiumEnabled; + openAiPremiumToggle.checked = premiumEnabled; } if (preferredOpenAiModel) { setStoredOpenAiSelectedModel(preferredOpenAiModel); } + if (preferredGeminiModel) { + setStoredGeminiSelectedModel(preferredGeminiModel); + } + setStoredPremiumModelProvider(preferredPremiumProvider); latestOpenAiModelProbe = null; + latestGeminiModelProbe = null; latestOllamaModelProbe = null; updateOpenAiModelStatusMessage(); updateOllamaModelStatusMessage(); @@ -5474,9 +5766,12 @@ async function loadAndDisplaySettings() { // Update Gmail profile status line updateGmailProfileStatus(); - // Check if the API key exists and verify it to load models - if (await checkOpenAiEnvVar()) { - await verifyOpenAiApiKey(currentOpenAIModel); // Pass the current OpenAI model + // Check the selected premium provider key and load its available models. + if (await checkPremiumProviderEnvVar(preferredPremiumProvider)) { + await verifyPremiumApiKey( + preferredPremiumProvider, + preferredPremiumProvider === 'gemini' ? preferredGeminiModel : preferredOpenAiModel, + ); } // Trigger height update after all settings content is loaded @@ -5856,10 +6151,13 @@ async function persistCurrentUserPreferences() { } } -export async function checkOpenAiEnvVar() { - const apiKeyEnvVar = document.getElementById('openaiApiKeyEnvVar').value; - const verifyButton = document.getElementById('verifyOpenAiApiKeyButton'); - const statusMessage = document.getElementById('openaiStatusMessage'); +export async function checkPremiumProviderEnvVar(providerName) { + const provider = normalisePremiumProvider(providerName) || 'openai'; + const config = PREMIUM_PROVIDER_CONFIG[provider]; + const apiKeyEnvVar = document.getElementById(config.envInputId)?.value; + const verifyButton = document.getElementById(config.verifyButtonId); + const statusMessage = document.getElementById(config.providerStatusId); + if (!apiKeyEnvVar || !verifyButton || !statusMessage) return false; try { const response = await postJson('/api/settings/env_var/check', { env_var_name: apiKeyEnvVar }); @@ -5869,9 +6167,11 @@ export async function checkOpenAiEnvVar() { verifyButton.style.display = 'inline-block'; verifyButton.disabled = false; if (response.source === 'dotenv') { - statusMessage.textContent = `Key found (from .env because not in process environment): ${response.masked_value}. Verify and test the selected model before enabling premium use.`; + statusMessage.textContent = `${config.label} key found in .env. Verify and test the selected model before enabling premium use.`; + } else if (response.source === 'secret_file' || response.source === 'dotenv_secret_file') { + statusMessage.textContent = `${config.label} key found in the configured secret file. Verify and test the selected model before enabling premium use.`; } else { - statusMessage.textContent = `Key found: ${response.masked_value}. Verify and test the selected model before enabling premium use.`; + statusMessage.textContent = `${config.label} key found. Verify and test the selected model before enabling premium use.`; } statusMessage.className = 'status-message'; statusMessage.style.display = 'block'; @@ -5886,7 +6186,7 @@ export async function checkOpenAiEnvVar() { return false; } } catch (error) { - console.error('Error checking environment variable:', error); + console.error(`Error checking ${config.label} environment variable:`, error); verifyButton.classList.add('hidden'); verifyButton.style.display = 'none'; verifyButton.disabled = true; @@ -5897,70 +6197,108 @@ export async function checkOpenAiEnvVar() { } } -async function verifyOpenAiApiKey(savedModel = null) { - const apiKeyEnvVar = document.getElementById('openaiApiKeyEnvVar').value; - const statusMessage = document.getElementById('openaiStatusMessage'); - const modelsContainer = document.getElementById('openaiModelsContainer'); +export async function checkOpenAiEnvVar() { + return checkPremiumProviderEnvVar('openai'); +} + +export async function checkGeminiEnvVar() { + return checkPremiumProviderEnvVar('gemini'); +} + +async function verifyPremiumApiKey(providerName, savedModel = null) { + const provider = normalisePremiumProvider(providerName) || 'openai'; + const config = PREMIUM_PROVIDER_CONFIG[provider]; + const apiKeyEnvVar = document.getElementById(config.envInputId)?.value; + const statusMessage = document.getElementById(config.providerStatusId); + const modelsContainer = document.getElementById(config.modelsContainerId); const preferredModel = normaliseLocalModelName(savedModel) - || getStoredOpenAiSelectedModel() + || (provider === 'gemini' ? getStoredGeminiSelectedModel() : getStoredOpenAiSelectedModel()) || null; setInlineStatusMessage( statusMessage, - 'Verifying API key and loading premium models...', + `Verifying ${config.label} API key and loading models...`, null, ); try { - const response = await postJson('/api/settings/openai/verify', { api_key_env_var: apiKeyEnvVar }); + const response = await postJson(config.verifyEndpoint, { api_key_env_var: apiKeyEnvVar }); if (response.success) { setInlineStatusMessage( statusMessage, - 'API key verified. Premium 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, ); - modelsContainer.style.display = 'block'; - modelsContainer.classList.remove('hidden'); + if (modelsContainer) { + modelsContainer.style.display = 'block'; + modelsContainer.classList.remove('hidden'); + } - renderOpenAIModelOptions('openaiModelSelect', response.models || [], preferredModel); - const selectedOpenAiModel = normaliseLocalModelName( - document.getElementById('openaiModelSelect')?.value, + if (provider === 'gemini') { + renderGeminiModelOptions(config.modelSelectId, response.models || [], preferredModel); + } else { + renderOpenAIModelOptions(config.modelSelectId, response.models || [], preferredModel); + } + const selectedModel = normaliseLocalModelName( + document.getElementById(config.modelSelectId)?.value, ); - if (selectedOpenAiModel) { - setStoredOpenAiSelectedModel(selectedOpenAiModel); + if (selectedModel) { + if (provider === 'gemini') { + setStoredGeminiSelectedModel(selectedModel); + } else { + setStoredOpenAiSelectedModel(selectedModel); + } } - await refreshOpenAiReasoningEffortControls(); - latestOpenAiModelProbe = null; - updateOpenAiModelStatusMessage(); + if (selectedPremiumProvider() === provider) { + await refreshPremiumReasoningEffortControls(provider); + } + setLatestPremiumModelProbe(provider, null); + updatePremiumModelStatusMessages(); void refreshSelectedOpenAiCostSummary(); } else { throw new Error(response.error || 'Failed to verify API key.'); } } catch (error) { - // As a fallback, try the direct OpenAI models endpoint + // Fall back to the provider's direct model-list endpoint. try { - await populateOpenAIModelDropdown('openaiModelSelect', preferredModel); + if (provider === 'gemini') { + await populateGeminiModelDropdown(config.modelSelectId, preferredModel); + } else { + await populateOpenAIModelDropdown(config.modelSelectId, preferredModel); + } setInlineStatusMessage( statusMessage, - 'Loaded a cached premium model list, but live verification failed. Test the selected model before enabling premium use.', + `Loaded the ${config.label} model list, but live verification failed. Test the selected model before enabling premium use.`, 'error', ); - modelsContainer.style.display = 'block'; - modelsContainer.classList.remove('hidden'); - latestOpenAiModelProbe = null; - updateOpenAiModelStatusMessage(); + if (modelsContainer) { + modelsContainer.style.display = 'block'; + modelsContainer.classList.remove('hidden'); + } + setLatestPremiumModelProbe(provider, null); + updatePremiumModelStatusMessages(); void refreshSelectedOpenAiCostSummary(); } catch (_) { setInlineStatusMessage(statusMessage, `Error: ${error.message}`, 'error'); - modelsContainer.style.display = 'none'; - modelsContainer.classList.add('hidden'); - latestOpenAiModelProbe = null; - updateOpenAiModelStatusMessage(); + if (modelsContainer) { + modelsContainer.style.display = 'none'; + modelsContainer.classList.add('hidden'); + } + setLatestPremiumModelProbe(provider, null); + updatePremiumModelStatusMessages(); } } } +async function verifyOpenAiApiKey(savedModel = null) { + return verifyPremiumApiKey('openai', savedModel); +} + +async function verifyGeminiApiKey(savedModel = null) { + return verifyPremiumApiKey('gemini', savedModel); +} + // Remove the old, separate save functions (saveGlobalModel, saveCurrentUser, saveOpenAISettings) // as they are now replaced by the single saveAllSettings function. diff --git a/src/frontend/web/von_interface/static/js/utils/localModelPreferences.js b/src/frontend/web/von_interface/static/js/utils/localModelPreferences.js index ff1293557..7b65949ed 100644 --- a/src/frontend/web/von_interface/static/js/utils/localModelPreferences.js +++ b/src/frontend/web/von_interface/static/js/utils/localModelPreferences.js @@ -5,6 +5,8 @@ const LS_OLLAMA_SELECTION = 'von:ollamaSelection'; const LS_PREMIUM_MODEL_USE_ENABLED = 'von:premiumModelUseEnabled'; const LOCAL_MODEL_PREFERENCE_SCHEMA = 'localModelPreference.v1'; const LOCAL_MODEL_UNAVAILABLE_REASON_NO_OLLAMA_MODEL = 'premium_disabled_no_ollama_model'; +const LOCAL_MODEL_UNAVAILABLE_REASON_NO_PREMIUM_MODEL = 'premium_enabled_no_model'; +const PREMIUM_MODEL_PROVIDERS = new Set(['openai', 'gemini']); function readStoredJson(key) { try { @@ -35,7 +37,12 @@ function readStoredString(key) { } function normaliseActiveSource(value) { - return value === 'openai' || value === 'ollama' ? value : null; + return value === 'openai' || value === 'gemini' || value === 'ollama' ? value : null; +} + +function normalisePremiumProvider(value) { + const provider = typeof value === 'string' ? value.trim().toLowerCase() : ''; + return PREMIUM_MODEL_PROVIDERS.has(provider) ? provider : null; } function isObjectStringArtifact(value) { @@ -102,22 +109,47 @@ export function normaliseModelParameters(value, capability = null) { function buildCanonicalLocalModelPreference({ activeSource = null, + premiumProvider = null, openaiModel = null, openaiModelParameters = null, + geminiModel = null, + geminiModelParameters = null, ollamaSelection = null, } = {}) { - const normalisedParameters = normaliseModelParameters(openaiModelParameters); + const normalisedActiveSource = normaliseActiveSource(activeSource); + const normalisedPremiumProvider = normalisePremiumProvider(premiumProvider) + || (PREMIUM_MODEL_PROVIDERS.has(normalisedActiveSource) ? normalisedActiveSource : null); + const normalisedOpenAiParameters = normaliseModelParameters(openaiModelParameters); + const normalisedGeminiParameters = normaliseModelParameters(geminiModelParameters); const canonical = { schemaVersion: LOCAL_MODEL_PREFERENCE_SCHEMA, - activeSource: normaliseActiveSource(activeSource), + activeSource: normalisedActiveSource, openaiModel: normaliseLocalModelName(openaiModel), ollamaSelection: normaliseOllamaSelection(ollamaSelection), }; - if (normalisedParameters) { - canonical.openaiModelParameters = normalisedParameters; + // OpenAI remains the backwards-compatible default premium provider. Persist + // the provider only when a different provider must survive an Ollama switch. + if (normalisedPremiumProvider === 'gemini') { + canonical.premiumProvider = 'gemini'; + } + const normalisedGeminiModel = normaliseLocalModelName(geminiModel); + if (normalisedGeminiModel) { + canonical.geminiModel = normalisedGeminiModel; + } + if (normalisedOpenAiParameters) { + canonical.openaiModelParameters = normalisedOpenAiParameters; + } + if (normalisedGeminiParameters) { + canonical.geminiModelParameters = normalisedGeminiParameters; } - if (!canonical.activeSource && !canonical.openaiModel && !canonical.ollamaSelection) { + if ( + !canonical.activeSource + && normalisedPremiumProvider !== 'gemini' + && !canonical.openaiModel + && !canonical.geminiModel + && !canonical.ollamaSelection + ) { return null; } @@ -129,10 +161,19 @@ function cloneLocalModelPreference(preference) { return { schemaVersion: preference.schemaVersion || LOCAL_MODEL_PREFERENCE_SCHEMA, activeSource: preference.activeSource || null, + ...(normalisePremiumProvider(preference.premiumProvider) === 'gemini' + ? { premiumProvider: 'gemini' } + : {}), openaiModel: normaliseLocalModelName(preference.openaiModel), ...(normaliseModelParameters(preference.openaiModelParameters) ? { openaiModelParameters: normaliseModelParameters(preference.openaiModelParameters) } : {}), + ...(normaliseLocalModelName(preference.geminiModel) + ? { geminiModel: normaliseLocalModelName(preference.geminiModel) } + : {}), + ...(normaliseModelParameters(preference.geminiModelParameters) + ? { geminiModelParameters: normaliseModelParameters(preference.geminiModelParameters) } + : {}), ollamaSelection: normaliseOllamaSelection(preference.ollamaSelection), }; } @@ -142,6 +183,7 @@ function normaliseStoredLocalModelPreference(stored) { return buildCanonicalLocalModelPreference({ activeSource: stored.activeSource ?? stored.active_source ?? null, + premiumProvider: stored.premiumProvider ?? stored.premium_provider ?? null, openaiModel: stored.openaiModel ?? stored.openai_model ?? null, openaiModelParameters: ( stored.openaiModelParameters @@ -149,6 +191,12 @@ function normaliseStoredLocalModelPreference(stored) { ?? stored.model_parameters ?? null ), + geminiModel: stored.geminiModel ?? stored.gemini_model ?? null, + geminiModelParameters: ( + stored.geminiModelParameters + ?? stored.gemini_model_parameters + ?? null + ), ollamaSelection: stored.ollamaSelection ?? stored.ollama_selection ?? null, }); } @@ -222,15 +270,16 @@ function persistLocalModelPreference(preference) { return canonical; } -function buildOpenAiRequestedLlm(modelName, modelParameters = null) { +function buildPremiumRequestedLlm(providerName, modelName, modelParameters = null) { + const provider = normalisePremiumProvider(providerName); const model = normaliseLocalModelName(modelName); - if (!model) return null; + if (!provider || !model) return null; const params = normaliseModelParameters(modelParameters); const requested = { - provider: 'openai', + provider, model, - requestModel: `openai:${model}`, + requestModel: `${provider}:${model}`, }; if (params) requested.model_parameters = params; return requested; @@ -265,18 +314,25 @@ function buildEffectiveLocalModelPreference(preference) { } let requestedLlm = null; - if (canonical.activeSource === 'openai') { - requestedLlm = buildOpenAiRequestedLlm( - canonical.openaiModel, - canonical.openaiModelParameters, + if (PREMIUM_MODEL_PROVIDERS.has(canonical.activeSource)) { + const provider = canonical.activeSource; + requestedLlm = buildPremiumRequestedLlm( + provider, + provider === 'gemini' ? canonical.geminiModel : canonical.openaiModel, + provider === 'gemini' + ? canonical.geminiModelParameters + : canonical.openaiModelParameters, ); } else if (canonical.activeSource === 'ollama') { requestedLlm = buildOllamaRequestedLlm(canonical.ollamaSelection); } - const modelUnavailableReason = canonical.activeSource === 'ollama' && !requestedLlm - ? LOCAL_MODEL_UNAVAILABLE_REASON_NO_OLLAMA_MODEL - : null; + let modelUnavailableReason = null; + if (!requestedLlm && canonical.activeSource === 'ollama') { + modelUnavailableReason = LOCAL_MODEL_UNAVAILABLE_REASON_NO_OLLAMA_MODEL; + } else if (!requestedLlm && PREMIUM_MODEL_PROVIDERS.has(canonical.activeSource)) { + modelUnavailableReason = LOCAL_MODEL_UNAVAILABLE_REASON_NO_PREMIUM_MODEL; + } return { ...canonical, @@ -317,16 +373,48 @@ export function getEffectiveLocalModelPreference() { } export function hasLocalPremiumModelUsePreference() { - return getEffectiveLocalModelPreference().activeSource === 'openai'; + return PREMIUM_MODEL_PROVIDERS.has(getEffectiveLocalModelPreference().activeSource); } export function getLocalPremiumModelUseEnabled(defaultValue = false) { const effective = getEffectiveLocalModelPreference(); if (!effective.activeSource) return !!defaultValue; - return effective.activeSource === 'openai'; + return PREMIUM_MODEL_PROVIDERS.has(effective.activeSource); +} + +export function getStoredPremiumModelProvider() { + const effective = getEffectiveLocalModelPreference(); + return normalisePremiumProvider(effective.premiumProvider) + || (PREMIUM_MODEL_PROVIDERS.has(effective.activeSource) ? effective.activeSource : null) + || 'openai'; +} + +export function setStoredPremiumModelProvider(providerName) { + const requestedProvider = normalisePremiumProvider(providerName); + if (providerName != null && !requestedProvider) { + return getStoredPremiumModelProvider(); + } + const provider = requestedProvider || 'openai'; + const stored = getStoredLocalModelPreference(); + const next = cloneLocalModelPreference(stored) || { + schemaVersion: LOCAL_MODEL_PREFERENCE_SCHEMA, + activeSource: null, + openaiModel: null, + ollamaSelection: null, + }; + if (provider === 'gemini') { + next.premiumProvider = 'gemini'; + } else { + delete next.premiumProvider; + } + if (PREMIUM_MODEL_PROVIDERS.has(next.activeSource)) { + next.activeSource = provider; + } + persistLocalModelPreference(next); + return provider; } -export function setLocalPremiumModelUseEnabled(enabled) { +export function setLocalPremiumModelUseEnabled(enabled, providerName = null) { const stored = getStoredLocalModelPreference(); const next = cloneLocalModelPreference(stored) || { schemaVersion: LOCAL_MODEL_PREFERENCE_SCHEMA, @@ -335,7 +423,16 @@ export function setLocalPremiumModelUseEnabled(enabled) { openaiModelParameters: null, ollamaSelection: null, }; - next.activeSource = enabled ? 'openai' : 'ollama'; + const provider = normalisePremiumProvider(providerName) + || normalisePremiumProvider(next.premiumProvider) + || (PREMIUM_MODEL_PROVIDERS.has(next.activeSource) ? next.activeSource : null) + || 'openai'; + if (provider === 'gemini') { + next.premiumProvider = 'gemini'; + } else { + delete next.premiumProvider; + } + next.activeSource = enabled ? provider : 'ollama'; persistLocalModelPreference(next); } @@ -378,6 +475,48 @@ export function setStoredOpenAiModelParameters(modelParameters) { persistLocalModelPreference(next); } +export function getStoredGeminiSelectedModel() { + return getEffectiveLocalModelPreference().geminiModel || ''; +} + +export function setStoredGeminiSelectedModel(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.geminiModel = model; + } else { + delete next.geminiModel; + } + persistLocalModelPreference(next); +} + +export function getStoredGeminiModelParameters() { + return getEffectiveLocalModelPreference().geminiModelParameters || null; +} + +export function setStoredGeminiModelParameters(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.geminiModelParameters = params; + } else { + delete next.geminiModelParameters; + } + persistLocalModelPreference(next); +} + export function getStoredOllamaSelection() { return getEffectiveLocalModelPreference().ollamaSelection || null; } @@ -411,6 +550,19 @@ export function resolveLocalRequestedLlm() { return getEffectiveLocalModelPreference().requestedLlm; } +export function buildLocalModelRequestFields(requestedLlm = resolveLocalRequestedLlm()) { + const provider = normaliseActiveSource(requestedLlm?.provider); + const model = normaliseLocalModelName(requestedLlm?.model); + if (!provider || !model) return {}; + + const modelParameters = normaliseModelParameters(requestedLlm?.model_parameters); + return { + model, + model_provider: provider, + ...(modelParameters ? { model_parameters: modelParameters } : {}), + }; +} + export function applyLocalModelPreferenceOverlay(settings) { const localPreference = getEffectiveLocalModelPreference(); if (!settings) return settings; diff --git a/src/frontend/web/von_interface/templates/settings_tab.html b/src/frontend/web/von_interface/templates/settings_tab.html index 049519a17..a91c9e79d 100644 --- a/src/frontend/web/von_interface/templates/settings_tab.html +++ b/src/frontend/web/von_interface/templates/settings_tab.html @@ -303,47 +303,73 @@

Scoped allowed models

-

Premium Models (OpenAI)

- - - +

Premium Models

+
+ + +
- +
- First allow the exact OpenAI model for this scope and save. Turn this off + First allow the exact provider and model for this scope and save. Turn this off to use the selected Ollama model for browser chat. Editing or testing a model does not allow it. -