Keep GitLab Duo requests and the health check local on offline licenses

What does this MR do and why?

With an offline cloud license, GitLab Duo requests addressed through Gitlab::AiGateway contact cloud.gitlab.com when Local AI Gateway URL is nil, and for some requests when a feature is set to a GitLab-managed model. They carry no credential that host issued, since the access token sync refuses any but an online cloud license (ee/app/services/cloud_connector/sync_cloud_connector_access_service.rb:42). Nil is the default without the AI_GATEWAY_URL environment variable (ee/app/models/ee/application_setting.rb:583) and what clearing the field stores (ee/app/graphql/mutations/ai/duo_settings/update.rb:178).

How a GitLab-managed setting arises, and how requests and the health check reached cloud.gitlab.com before this change

On an offline license the model dropdown lists no GitLab-managed models (ee/app/services/ai/model_selection/fetch_model_definitions_service.rb:17), so such a setting is made outside the admin page, such as in the Rails console, or left over from a license change.

  • Gitlab::AiGateway falls back to cloud.gitlab.com when the URL is nil (ee/lib/gitlab/ai_gateway.rb:21-41). A feature setting on a GitLab-managed model routes its request there whatever the URL (ee/app/models/ai/feature_setting.rb:143, ee/lib/gitlab/ai_gateway.rb:33-35).
  • The health check ran its self-hosted checks only when the URL was set (ee/app/services/cloud_connector/status_checks/status_service.rb:36). Otherwise it ran the cloud-connected checks, which probe the subscription portal and cloud.gitlab.com (ee/app/services/cloud_connector/status_checks/probes/registry.rb:48-60) and, with the URL nil, send a test completion there (ee/app/services/cloud_connector/status_checks/probes/end_to_end_probe.rb:33). With the URL set, any feature on a GitLab-managed model added the cloud-connected checks too (registry.rb:31-36, :109-116).

This MR stops each request before it is made. Where the caller reports an error, it names only the wrong setting: Set the "Local AI Gateway URL" in your duo settings, or Assign a self-hosted model to the "<feature>" feature settings in your duo settings, or both. Each message is one whole translated sentence under AiPowered|, and all three open the same way (ee/lib/gitlab/ai_gateway.rb:65-81). On an offline cloud license the health check always runs the self-hosted checks (ee/app/services/cloud_connector/status_checks/status_service.rb:36-38).

Requests resolve their URL through Gitlab::AiGateway.request_url! or access_token_url! (ee/lib/gitlab/ai_gateway.rb:83-93). Each caller passes what routes its request: the feature setting, or for code embeddings the model definition's use_cloud_aigw. On an offline cloud license, when that route is the cloud connector, the resolver raises NoCloudConnectorTokenError with the message above and no request is sent (:58-63, :251-256).

  • Gitlab::AiGateway.url still never raises, since code that sends no request reads it, such as the Web IDE CSP on every Web IDE page (ee/app/controllers/concerns/ee/web_ide_csp.rb:15).
  • With DEVELOPMENT_AI_GATEWAY_URL set, the cloud connector URLs resolve to it (:25-31), so the resolver refuses nothing (:60).

Each caller handles NoCloudConnectorTokenError in the failure shape its own callers already expect:

  • Code Suggestions completions and token return a 400.
  • Classic Duo Chat's agent step, tools and slash commands let NoCloudConnectorTokenError propagate to their existing rescues.
  • Duo Chat documentation search returns nil.
  • AI Gateway completions return nil, or a detail naming the setting.
  • Code embeddings raise EmbeddingsGenerationError.
  • Amazon Q reports it where each caller reports a failure: the admin settings form, the health check, the /q failure note, or a log entry.
  • Third-party agent token requests return a 503.
  • The Anthropic and Vertex AI proxy clients raise it to their caller, though no production code builds them today.
Where each request resolves its URL, how it reports the refusal, and what the health check reports
Request Resolved at Result on an offline cloud license
Code Suggestions completions, POST /api/v4/code_suggestions/completions ee/lib/code_suggestions/model_details/base.rb:20, rescued at ee/lib/api/code_suggestions.rb:240-247 a 400 in the shape of the existing missing-feature-setting error
Code Suggestions token, POST /api/v4/code_suggestions/direct_access ee/lib/gitlab/llm/ai_gateway/code_suggestions_client.rb:63, rescued at :75-76 the same 400, also when its raw Code Completion row is GitLab-managed (:119-121)
Classic Duo Chat agent step ee/lib/gitlab/duo/chat/step_executor.rb:122, through #base_url (:196-198) NoCloudConnectorTokenError, a ConnectionError (ee/lib/gitlab/llm/ai_gateway/client.rb:13), which Chat answers with a translated message and A1001 (ee/lib/gitlab/duo/chat/react_executor.rb:101-109)
Classic Duo Chat tools and slash commands ee/lib/gitlab/llm/chain/requests/ai_gateway.rb:101, in #endpoint the same error, to each tool's existing rescue. Slash commands keep their generic M4000 error (ee/lib/gitlab/llm/chain/tools/slash_command_tool.rb:20-27), and llm.log names the setting (ee/lib/gitlab/llm/chain/answer.rb:84)
Duo Chat documentation search ee/lib/gitlab/llm/ai_gateway/docs_client.rb:72, rescued at :63-68 nil, which its only caller reads as no results (ee/lib/gitlab/llm/chain/tools/gitlab_documentation/executor.rb:85-87). It reads the raw General Chat row (docs_client.rb:75-77), so a GitLab-managed setting there refuses even with the URL set
AI Gateway completions: Completions::Base features, the git command, evaluators ee/lib/gitlab/llm/concerns/ai_gateway_client_concern.rb:89, rescued at :62-67. Completions::Base resolves first (ee/lib/gitlab/llm/ai_gateway/completions/base.rb:38) and rescues at :45-46 nil, the concern's failed-request value (ai_gateway_client_concern.rb:57), or from Completions::Base a detail naming the setting, as a failed request returns (base.rb:44)
Code embeddings ee/lib/gitlab/llm/embeddings/model_definition.rb:58, rescued at ee/lib/gitlab/llm/embeddings/client.rb:63-64 EmbeddingsGenerationError, as a failed response raises (ee/lib/gitlab/llm/embeddings/code_embeddings.rb:60), which indexing logs and retries (ee/lib/ai/active_context/references/code.rb:8-16)
Amazon Q ee/lib/gitlab/llm/q_ai/client.rb:102 NoCloudConnectorTokenError, which each caller reports as its own failure: an admin settings error on :base (ee/app/services/ai/amazon_q/create_service.rb:139-142, destroy_service.rb:45-48), a failed health check result (ee/app/services/cloud_connector/status_checks/probes/amazon_q/end_to_end_probe.rb:49-50), the /q failure note (ee/app/services/ai/amazon_q/amazon_q_trigger_service.rb:39-40), or a log entry for a webhook event (ee/app/models/concerns/integrations/base/amazon_q.rb:108-111)
Third-party agent token, POST /api/v4/ai/third_party_agents/direct_access ee/lib/ai/third_party_agents/token_service.rb:35, rescued at :57-58 the error a failed token request returns (token_service.rb:43), which the API answers with a 503 (ee/lib/api/ai/third_party_agents.rb:43)
Anthropic and Vertex AI proxy clients ee/lib/gitlab/llm/anthropic/client.rb:154, ee/lib/gitlab/llm/vertex_ai/model_configurations/base.rb:18 NoCloudConnectorTokenError to the caller. Outside specs, a search finds the clients built only in ee/lib/gitlab/llm/chain/requests/anthropic.rb:14 and vertex_ai.rb:13, and those requests built nowhere

On an offline cloud license, under AI Gateway, the health check reports the URL not set or names each feature on a GitLab-managed model. It now follows the feature setting selection, which on an offline license ignores a GitLab-managed setting (ee/app/services/ai/feature_setting_selection_service.rb:51-53) and drops an instance-level model selection (:41-44). It adds no cloud-connected checks for either (registry.rb:133) and reports only the feature settings (registry.rb:126-130), since an instance-level selection routes no request. On every license, the URL check's passing message now names the field as the settings page labels it, Local AI Gateway URL, in place of Self hosted AI Gateway URL.

Refusal path

flowchart LR
    R["a Duo request<br/>builds its URL"] --> RU["Gitlab::AiGateway.request_url!<br/>or access_token_url!"]
    RU --> C{"no_cloud_connector_token?<br/>offline license and a<br/>cloud connector route"}
    C -->|yes| E["raise NoCloudConnectorTokenError<br/>no request sent"]
    E --> X["each caller handles it<br/>in its own failure shape"]
    X --> CS["Code Suggestions API and CodeSuggestionsClient<br/>400"]
    X --> CH["StepExecutor, Requests::AiGateway<br/>propagate to Chat's A1001 answer and tool rescues"]
    X --> DC["DocsClient, AiGatewayClientConcern<br/>log and nil"]
    X --> BA["Completions::Base<br/>detail naming the setting"]
    X --> EM["Embeddings::Client<br/>EmbeddingsGenerationError"]
    X --> TP["ThirdPartyAgents::TokenService<br/>error, API 503"]
    X --> Q["QAi::Client callers<br/>settings error, probe result, /q failure note, log"]
    X --> AV["Anthropic::Client, VertexAi model configurations<br/>raised to the caller"]
    C -->|no| S["URL returned<br/>request sent"]

Request outcome by setting

flowchart LR
    CS["Code Suggestions<br/>completions, direct_access"] --> O
    CH["classic Duo Chat<br/>StepExecutor, Requests::AiGateway, DocsClient"] --> O
    CO["AiGatewayClientConcern<br/>Completions::Base"] --> O
    EM["Embeddings::ModelDefinition"] --> O
    Q["QAi::Client"] --> O
    TP["ThirdPartyAgents::TokenService"] --> O
    AV["Anthropic::Client<br/>VertexAi model configurations"] --> O
    O{"Gitlab::AiGateway.request_url!, access_token_url!<br/>offline cloud license?"}
    O -->|no| N["unchanged"]
    O -->|yes| D{"DEVELOPMENT_AI_GATEWAY_URL set?"}
    D -->|yes| DV["no refusal: the cloud connector URLs<br/>resolve to the developer's AI gateway"]
    D -->|no| U{"ai_gateway_url"}
    U -->|"nil, the default"| E["before: request to cloud.gitlab.com<br/>after: no request, the caller's failure shape,<br/>naming the URL setting where it reports one"]
    U -->|"empty string"| F["before: an error with no connection, or cloud.gitlab.com<br/>through a GitLab-managed setting<br/>after: no request, as for nil"]
    U -->|set| V{"GitLab-managed setting read by direct_access,<br/>DocsClient or code embeddings?"}
    V -->|yes| G["before: request to cloud.gitlab.com<br/>after: no request, the caller's failure shape,<br/>naming the model setting where it reports one"]
    V -->|no| H["local AI gateway<br/>unchanged"]

Health check by setting

flowchart LR
    H["cloudConnectorStatus<br/>StatusService#selected_probes"] --> O{"offline cloud<br/>license?"}
    O -->|no| N["same checks"]
    O -->|yes| S{"ai_gateway_url set?"}
    S -->|no| X["before: cloud-connected checks, host probes and, with<br/>the URL nil, a test completion to cloud.gitlab.com<br/>after: self-hosted checks, URL reported not set"]
    S -->|yes| V{"a feature on a GitLab-managed model,<br/>or an instance-level model selection?"}
    V -->|no| SH["same self-hosted checks"]
    V -->|yes| Y["before: also host probes to cloud.gitlab.com and the subscription portal<br/>after: self-hosted checks only, each feature on a GitLab-managed<br/>model named, an instance-level selection not reported"]

The diff is 1,547 lines, 1,308 of them specs. The first eight commits add the check, the Code Suggestions guards and the health check. The next seven guard one more caller each, with its specs. One more fixes a spec that failed in some run orders. The last eleven follow review: they translate the messages, let DEVELOPMENT_AI_GATEWAY_URL through, add the resolver and move each caller onto it. It reads commit by commit and lands as one squashed commit.

An AI coding assistant wrote this change and ran the checks below in a simulated environment: a GitLab source checkout and a 19.4.1 test instance.

References

Screenshots or screen recordings

After this change, with Local AI Gateway URL unset, Run health check and a Duo Chat question:

The health check reports Local AI Gateway URL is not set

Duo Chat answers that this instance needs a Local AI Gateway URL and a self-hosted model

With the URL set and Code Completion and General Chat on a GitLab-managed model:

The health check names Code Completion and General Chat under AI Gateway

How to set up and validate locally

Steps, and what they showed on 19.4.1
  1. With an offline cloud license, assign a self-hosted model to Code Completion and clear Local AI Gateway URL.
  2. POST /api/v4/code_suggestions/completions with a current_file, POST /api/v4/code_suggestions/direct_access, and Run health check on Admin > GitLab Duo. Expected: the requests return a 400 naming Local AI Gateway URL only, the health check reports it not set, and nothing connects to cloud.gitlab.com.
  3. Assign self-hosted models to General Chat, Code Explanation and GitLab Duo for CLI too. Ask Duo Chat a question (the aiAction mutation with chat), send /explain on a selected snippet, POST /api/v4/ai/llm/git_command and POST /api/v4/ai/third_party_agents/direct_access, and through gitlab-rails runner call Gitlab::Llm::AiGateway::DocsClient.new(user).search(query: 'How do I create an issue?'), since a Chat question with the URL cleared, or with General Chat GitLab-managed, stops at the agent step first. Expected: each answers as in the table below.
  4. Set the URL and repeat. Expected: the local AI gateway serves the completion, and the health check reports the URL set and probes it.
  5. Set Code Completion and General Chat to vendored with Ai::FeatureSettings::UpdateService in a Rails console and repeat, the search included. Expected: the token request returns the 400 naming the model assignment only, the health check names both features under AI Gateway, and nothing connects to cloud.gitlab.com or the subscription portal.
  6. bundle exec rspec on the changed spec files, and yarn jest ee/spec/frontend/usage_quotas/code_suggestions/utils_spec.js.
On 19.4.1 with an offline cloud license Before With this change
Token request, URL set, Code Completion GitLab-managed reached cloud.gitlab.com, 401 the 400 naming Code completion
Health check, same state reached cloud.gitlab.com and the subscription portal with General Chat GitLab-managed too, named both features under AI Gateway and contacted neither host
Completions request, URL cleared, Code Completion self-hosted reached cloud.gitlab.com, 401 the 400 naming the URL, as did the token request
Health check, URL cleared reached cloud.gitlab.com and the subscription portal, and the test completion got a 401 the self-hosted checks, URL reported not set
Completions and health check, URL set, self-hosted model the local AI gateway served completions the same, and the health check probed the URL
Duo Chat question, URL cleared reached cloud.gitlab.com, 401, and Chat answered I'm sorry, I can't generate a response. Please try again. (A9999) no request, and at once the translated answer (A1001)
/explain on a snippet, URL cleared reached cloud.gitlab.com, 401, and the same answer (M4000) no request, and the same answer (M4000)
POST /api/v4/ai/llm/git_command, URL cleared reached cloud.gitlab.com, 401, and the API returned 201 with a null body no request, and 201 with a null body
POST /api/v4/ai/third_party_agents/direct_access, URL cleared reached cloud.gitlab.com, and the API returned 503 Token creation failed no request, and a 503 naming Local AI Gateway URL
DocsClient#search, URL cleared reached cloud.gitlab.com, 401 no request, and nil
DocsClient#search, URL set, General Chat GitLab-managed reached cloud.gitlab.com, 401 no request, nil, and llm.log names General Chat

With this change, each result held with internet access and without it, the last six refusing at once with no connection attempt. With the URL set and self-hosted models, Chat, /explain, the git command and the search also reached the local AI gateway, and with an online cloud license and the URL unset, Chat, /explain, the search and the agent token still reached cloud.gitlab.com.

Before this change and without internet access, the last six still tried cloud.gitlab.com and failed, most after about a minute, and the git command returned a 500.

These instance results come from the commits before review. The review commits move where each refusal is raised and keep what each request in the table returns, and their specs pass on the final commit.

Code embeddings and Amazon Q rest on their specs alone, since they need an ActiveContext vector store and an Amazon Q connection to AWS.

  • RuboCop is clean on the changed Ruby files, and the gettext checks pass on the regenerated locale/gitlab.pot.
  • Before review, 41 new or changed examples failed against the current code. In the review commits, swapping any resolver call back for the non-raising URL, or removing any new rescue, fails a spec. With this change, all pass.
  • An existing example in ee/spec/lib/gitlab/llm/concerns/ai_gateway_client_concern_spec.rb failed in some run orders, on master too, because the shared user kept a group membership memoized from an earlier example. Commit d19310ec fixes it.
  • The new Jest example fails against the current code and passes with this change.
  • Neighboring specs pass unchanged, apart from three Amazon Q admin page renders that need compiled frontend assets the checkout did not have.

Danger's AI logging warning asks whether new logging belongs behind expanded_ai_logging. Each new log_error logs only the refusal text above, which names a setting or a feature title and carries no prompt, input or response, so none is gated on it.

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Edited by Andrew Dunn

Merge request reports

Loading
Loading