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::AiGatewayfalls back tocloud.gitlab.comwhen 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 andcloud.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.urlstill 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_URLset, 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
NoCloudConnectorTokenErrorpropagate to their existing rescues. - Duo Chat documentation search returns
nil. - AI Gateway completions return
nil, or adetailnaming the setting. - Code embeddings raise
EmbeddingsGenerationError. - Amazon Q reports it where each caller reports a failure: the admin settings form, the health check, the
/qfailure 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
- Closes #629912
Screenshots or screen recordings
After this change, with Local AI Gateway URL unset, Run health check and a Duo Chat question:
With the URL set and Code Completion and General Chat on a GitLab-managed model:
How to set up and validate locally
Steps, and what they showed on 19.4.1
- With an offline cloud license, assign a self-hosted model to Code Completion and clear Local AI Gateway URL.
POST /api/v4/code_suggestions/completionswith acurrent_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 tocloud.gitlab.com.- Assign self-hosted models to General Chat, Code Explanation and GitLab Duo for CLI too. Ask Duo Chat a question (the
aiActionmutation withchat), send/explainon a selected snippet,POST /api/v4/ai/llm/git_commandandPOST /api/v4/ai/third_party_agents/direct_access, and throughgitlab-rails runnercallGitlab::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. - Set the URL and repeat. Expected: the local AI gateway serves the completion, and the health check reports the URL set and probes it.
- Set Code Completion and General Chat to
vendoredwithAi::FeatureSettings::UpdateServicein 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 tocloud.gitlab.comor the subscription portal. bundle exec rspecon the changed spec files, andyarn 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.rbfailed 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.


