Skip to content

feat(provider): add Google Vertex AI provider#8621

Open
starmiaoa wants to merge 2 commits into
AstrBotDevs:masterfrom
starmiaoa:feat/google-vertex-ai
Open

feat(provider): add Google Vertex AI provider#8621
starmiaoa wants to merge 2 commits into
AstrBotDevs:masterfrom
starmiaoa:feat/google-vertex-ai

Conversation

@starmiaoa
Copy link
Copy Markdown

@starmiaoa starmiaoa commented Jun 6, 2026

通过 Vertex AI 原生 Gemini API 接入 Google 模型,作为 AstrBot 的一个独立模型提供商预设(Google Vertex AI)。复用现有的 googlegenai_chat_completion 适配器,而不是走 OpenAI-compatible 临时路径。

Closes #7781
Closes #7653

Modifications / 改动点

  • 新增 Google Vertex AI provider source 预设(provider: google-vertex-ai,type: googlegenai_chat_completion):基础配置只暴露 id / api_base,高级配置复用 Gemini 既有能力(安全过滤、思考配置、原生搜索 / 代码执行 / URL 上下文、图片模态、timeout、proxy)。

  • 两种鉴权方式:

    • 服务账号 JSON:用于生成请求,并可通过 Vertex AI Model Garden publisher 端点拉取模型列表;project_id 缺省时自动从 JSON 中读取。
    • Vertex AI API Key:仅用于生成请求(Google 侧暂不支持用 API Key 拉取模型列表,此场景会给出明确提示,可手动添加模型 ID)。
  • 新增 astrbot/core/provider/sources/vertex_ai.py:配置归一化、OAuth token 刷新(支持 proxy)、模型 ID 互转(google/*publishers/google/models/*)、模型列表拉取等共享 helper。

  • gemini_source.py 复用同一套 ProviderGoogleGenAI,Vertex 路径以 vertexai=True 初始化 google-genai SDK;Gemini 的高级选项对 Vertex 同样生效(无额外屏蔽分支)。

  • Dashboard:Vertex AI 专用配置字段、i18n 文案(zh-CN / en-US / ru-RU)以及供应商 logo。

  • 新增依赖 google-auth[requests]>=2.41.1(已写入 requirements.txtpyproject.toml)。

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

新增单元测试 tests/test_vertex_ai.py,覆盖配置归一化、API Key / 服务账号 JSON 两条路径、原生 Vertex 端点、模型列表拉取、模型 ID 转换以及配置 metadata 与中文文案:

$ python -m pytest tests/test_vertex_ai.py -q
.........................                                                [100%]
25 passed

ruff checkruff format --check 均通过。

说明:单测均基于 mock。逻辑接线、URL 构造与鉴权流程已被 mock 覆盖,但尚未在真实 Vertex 项目上做端到端联网验证(暂无可用凭据)。


Checklist / 检查清单


🤖 Generated with Claude Code

Summary by Sourcery

Add a dedicated Google Vertex AI provider using the native Gemini API and integrate it with existing Gemini provider infrastructure and dashboard configuration.

New Features:

  • Introduce a Google Vertex AI provider preset wired to the googlegenai_chat_completion adapter with dedicated auth and location settings.
  • Support Vertex AI authentication via either API key or Google Cloud service account JSON, including project ID resolution and token refresh.
  • Expose Vertex AI-specific configuration fields and icons in the dashboard, including tailored basic/advanced forms and i18n metadata for multiple locales.

Enhancements:

  • Refactor the Gemini provider to reuse shared Vertex AI helpers for endpoint selection, model name normalization, and model listing against Vertex Model Garden.
  • Normalize Vertex AI provider configs at load and update time to keep runtime keys and persisted configs consistent and backward compatible.
  • Improve Gemini logging for content generation responses with structured debug details.

Build:

  • Add google-auth[requests] as a new dependency for Vertex AI service account authentication.

Tests:

  • Add a comprehensive Vertex AI test suite covering config normalization, auth flows, endpoint construction, model listing, model ID mapping, dashboard metadata, and i18n entries.

Add a native Google Vertex AI provider preset that reuses the
googlegenai_chat_completion adapter to call Gemini models through the
Vertex AI native API (instead of an OpenAI-compatible shim).

- New "Google Vertex AI" provider source template (provider:
  google-vertex-ai) exposing id/api_base as basic config plus the Gemini
  advanced options (safety settings, thinking config, native search,
  url context, timeout, proxy, ...).
- Two auth modes: service-account JSON (also enables model discovery via
  the Vertex AI Model Garden publisher endpoint) and Vertex AI API key
  (generation only). The project id is auto-resolved from the
  service-account JSON when omitted.
- Shared helpers in vertex_ai.py: config normalization, OAuth token
  refresh with proxy support, AstrBot <-> Vertex model-id mapping
  (google/* <-> publishers/google/models/*) and model-list fetching.
- Dashboard: dedicated Vertex AI config fields, i18n copy for
  zh-CN/en-US/ru-RU and the provider logo.
- Add the google-auth[requests] dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. labels Jun 6, 2026
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • The Vertex AI model name normalization logic is currently split between vertex_ai.to_vertex_ai_genai_model_name, ProviderGoogleGenAI._normalize_model_name, and fetch_vertex_ai_publisher_models; consider centralizing this in a single helper to avoid future divergence in how IDs are transformed.
  • In make_vertex_ai_refresh_request, when a proxy is configured but requests is not installed, the function silently falls back to a non-proxied Request; consider logging or surfacing this mismatch so users understand why their proxy setting is not being honored.
  • In _get_genai_client_kwargs, when vertex_ai_auth_type is api_key but a service-account JSON is also present, the code implicitly prefers service-account auth; consider making this precedence explicit (e.g., by validating against mixed configuration or documenting the priority) to avoid confusing misconfigurations.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The Vertex AI model name normalization logic is currently split between `vertex_ai.to_vertex_ai_genai_model_name`, `ProviderGoogleGenAI._normalize_model_name`, and `fetch_vertex_ai_publisher_models`; consider centralizing this in a single helper to avoid future divergence in how IDs are transformed.
- In `make_vertex_ai_refresh_request`, when a proxy is configured but `requests` is not installed, the function silently falls back to a non-proxied `Request`; consider logging or surfacing this mismatch so users understand why their proxy setting is not being honored.
- In `_get_genai_client_kwargs`, when `vertex_ai_auth_type` is `api_key` but a service-account JSON is also present, the code implicitly prefers service-account auth; consider making this precedence explicit (e.g., by validating against mixed configuration or documenting the priority) to avoid confusing misconfigurations.

## Individual Comments

### Comment 1
<location path="astrbot/core/provider/sources/gemini_source.py" line_range="1118-1122" />
<code_context>
+                "并填写 Google Cloud 服务账号 JSON 后获取模型列表,或手动添加模型。"
+            )
+
+        credentials = self._load_vertex_ai_service_account_credentials()
+        if not getattr(credentials, "valid", False) or not getattr(
+            credentials, "token", None
+        ):
+            credentials.refresh(make_vertex_ai_refresh_request(self.provider_config))
+
+        token = getattr(credentials, "token", None)
</code_context>
<issue_to_address>
**issue (bug_risk):** Refreshing Vertex AI credentials synchronously inside an async context risks blocking the event loop.

In `_get_vertex_ai_models`, `credentials.refresh(...)` does synchronous network I/O (`google-auth` + `requests`) inside an `async` function, which can block the event loop under slow networks. Please move the refresh into a thread (e.g. `await anyio.to_thread.run_sync(credentials.refresh, make_vertex_ai_refresh_request(self.provider_config))` or `loop.run_in_executor(...)`) so the async runtime stays responsive while tokens are refreshed.
</issue_to_address>

### Comment 2
<location path="astrbot/core/provider/sources/gemini_source.py" line_range="51" />
<code_context>
+)


 class SuppressNonTextPartsWarning(logging.Filter):
</code_context>
<issue_to_address>
**issue (complexity):** Consider moving most Vertex AI–specific URL, auth, and model-handling logic into shared helpers in vertex_ai.py so GeminiSource stays a thin, generic consumer of those utilities.

You can simplify this by pushing more of the Vertex-specific decisions into `vertex_ai.py` and turning `GeminiSource` into a thin consumer of those helpers. That will reduce duplication and keep the Vertex logic in one place.

### 1. Centralize base URL / SDK `HttpOptions` logic

`_get_vertex_ai_sdk_base_url` re-implements base URL normalization that’s already conceptually handled in `vertex_ai.py`. Instead, add a helper there that returns the SDK-ready base URL (and api_version when needed) and call it from `_init_client`.

**vertex_ai.py (new helper):**

```python
# vertex_ai.py

def build_vertex_ai_http_options(provider_config, timeout_seconds: int) -> dict:
    """
    Return kwargs for types.HttpOptions for Vertex AI:
    {
        "base_url": ...,
        "timeout": ...,
        "api_version": ...
    }
    """
    configured_base_url = str(provider_config.get("api_base") or "").strip().rstrip("/")
    location = normalize_vertex_ai_location(provider_config.get("vertex_ai_location"))

    base_url = _normalize_base_url(configured_base_url, location)
    # _normalize_base_url can encapsulate logic now in _get_vertex_ai_sdk_base_url

    kwargs: dict = {
        "base_url": base_url,
        "timeout": timeout_seconds * 1000,
        "api_version": VERTEX_AI_API_VERSION,
    }
    return kwargs
```

**GeminiSource._init_client (simplified):**

```python
def _init_client(self) -> None:
    proxy = self.provider_config.get("proxy", "")
    is_vertex_ai = self._is_vertex_ai_config()

    if is_vertex_ai:
        http_options_kwargs = build_vertex_ai_http_options(
            self.provider_config, self.timeout
        )
    else:
        http_options_kwargs = {
            "base_url": self.api_base,
            "timeout": self.timeout * 1000,
        }

    http_options = types.HttpOptions(**http_options_kwargs)

    async_client_kwargs: dict = {"timeout": self.timeout}
    base_url = http_options_kwargs.get("base_url")
    if base_url:
        async_client_kwargs["base_url"] = base_url
    ...
    self._http_client = httpx.AsyncClient(**async_client_kwargs)
    http_options.httpx_async_client = self._http_client

    client_kwargs = self._get_genai_client_kwargs(http_options)
    self.client = genai.Client(**client_kwargs).aio
```

This removes `_get_vertex_ai_sdk_base_url` entirely and delegates base URL behavior to the dedicated module.

### 2. Move auth decision tree into `vertex_ai.py`

`_get_genai_client_kwargs`, `_load_vertex_ai_service_account_credentials`, `_resolve_vertex_ai_project_id_required`, `_has_vertex_ai_key_json`, `_ensure_vertex_ai_api_key_for_generation` all mix auth-type resolution with SDK wiring. A single central helper in `vertex_ai.py` can return a ready-to-use `client_kwargs` dict, so `GeminiSource` only needs to decide “Vertex vs non-Vertex”.

**vertex_ai.py (new helper):**

```python
# vertex_ai.py

def build_vertex_ai_client_kwargs(provider_config, http_options) -> dict:
    auth_type = normalize_vertex_ai_auth_type(
        provider_config.get("vertex_ai_auth_type")
    )

    if auth_type == VERTEX_AI_API_KEY_AUTH and not extract_vertex_ai_credentials_json(provider_config).strip():
        # API-key only, no service account JSON
        return {
            "vertexai": True,
            "http_options": http_options,
            "api_key": provider_config.get("api_key") or "",
        }

    if auth_type in {VERTEX_AI_API_KEY_AUTH, VERTEX_AI_JSON_AUTH}:
        credentials = load_vertex_ai_service_account_credentials(provider_config)
        project = resolve_vertex_ai_project_id(provider_config)
        if not project:
            raise ValueError("Vertex AI project id is required for service account auth.")
        return {
            "vertexai": True,
            "http_options": http_options,
            "credentials": credentials,
            "project": project,
            "location": normalize_vertex_ai_location(
                provider_config.get("vertex_ai_location")
            ),
        }

    raise ValueError(
        "Vertex AI key format must be api_key or json for Google GenAI provider."
    )
```

(You can move the existing service-account loading logic into `load_vertex_ai_service_account_credentials` here instead of duplicating it in `GeminiSource`.)

**GeminiSource._get_genai_client_kwargs (simplified):**

```python
def _get_genai_client_kwargs(self, http_options: types.HttpOptions) -> dict:
    if not self._is_vertex_ai_config():
        return {
            "api_key": self.chosen_api_key,
            "http_options": http_options,
        }

    return build_vertex_ai_client_kwargs(self.provider_config, http_options)
```

**GeminiSource._ensure_vertex_ai_api_key_for_generation (can be removed or wrapped):**

If you still want the guard for pure API-key mode, you can implement that in `vertex_ai.py` (e.g., `ensure_vertex_ai_api_key_for_generation(provider_config, chosen_api_key)`) and call that from `_query` / `_query_stream`, instead of re-implementing the same decision logic locally.

### 3. Centralize model-name normalization & listing

Model name logic is now split across `_get_request_model_name`, `_normalize_model_name`, `to_vertex_ai_genai_model_name`, and Vertex publisher listing. You can delegate most of this into `vertex_ai.py` too.

**vertex_ai.py (new helpers):**

```python
# vertex_ai.py

def normalize_vertex_ai_sdk_model_name(name: str) -> str:
    # logic currently in _normalize_model_name for Vertex
    name = name.strip()
    if VERTEX_AI_MODEL_NAME_SEPARATOR in name:
        name = name.rsplit(VERTEX_AI_MODEL_NAME_SEPARATOR, 1)[1]
    elif name.startswith("models/"):
        name = name.removeprefix("models/")
    if not name.startswith("google/"):
        name = f"google/{name}"
    return name

def normalize_gemini_model_name(name: str) -> str:
    return name.strip().replace("models/", "")
```

**GeminiSource:**

```python
def _get_request_model_name(self, model: str) -> str:
    if self._is_vertex_ai_config():
        return to_vertex_ai_genai_model_name(model)
    return model

def _normalize_model_name(self, name: str) -> str:
    if self._is_vertex_ai_config():
        return normalize_vertex_ai_sdk_model_name(name)
    return normalize_gemini_model_name(name)
```

For listing models, consider moving the Vertex-specific `get_models` logic into one helper (e.g., `fetch_vertex_ai_models(provider_config, http_client)`) that encapsulates the token refresh, URL building, and header construction. `GeminiSource.get_models` then just branches and forwards:

```python
async def get_models(self):
    if self._is_vertex_ai_config():
        return await fetch_vertex_ai_models(self.provider_config, self._http_client)

    try:
        models = await self.client.models.list()
        model_ids = [
            self._normalize_model_name(m.name)
            for m in models
            if m.supported_actions
            and "generateContent" in m.supported_actions
            and m.name
        ]
        return normalize_and_dedupe_strings(model_ids)
    except APIError as e:
        raise Exception(f"获取模型列表失败: {e.message}")
```

This way, `GeminiSource` remains responsible for high-level decisions (Vertex vs non-Vertex, streaming vs non-streaming) and delegates endpoint/auth/model-name details to `vertex_ai.py`, reducing the cognitive load and duplication without altering functionality.
</issue_to_address>

### Comment 3
<location path="dashboard/src/composables/useProviderSources.ts" line_range="187" />
<code_context>
   const basicSourceConfig = computed(() => {
     if (!editableProviderSource.value) return null

-    const fields = ['id', 'key', 'api_base']
+    const fields = editableProviderSource.value.provider === 'google-vertex-ai'
+      ? ['id', 'api_base']
</code_context>
<issue_to_address>
**issue (complexity):** Consider centralizing provider-specific field configuration and defaulting logic into dedicated helpers so the composable stays provider-agnostic and easier to extend.

You can keep the new Vertex behavior but reduce complexity by centralizing the provider‑specific rules into a single configuration object instead of scattering `provider === 'google-vertex-ai'` checks.

### 1. Centralize basic/advanced field config per provider

Right now `basicSourceConfig`, `advancedSourceConfig`, `vertexHiddenFields`, `vertexAdvancedSourceFields`, and the Vertex‑specific branching are separate. You can keep behavior identical by introducing a config map:

```ts
const PROVIDER_FIELD_CONFIG = {
  default: {
    basic: ['id', 'key', 'api_base'],
    hidden: new Set(['id', 'key', 'api_base', 'enable', 'type', 'provider_type', 'provider']),
    advancedOrder: null as string[] | null
  },
  'google-vertex-ai': {
    basic: ['id', 'api_base'],
    hidden: new Set([
      'id',
      'key',
      'api_base',
      'enable',
      'type',
      'provider_type',
      'provider',
      'vertex_ai_project_id',
      'vertex_ai_credentials_path'
    ]),
    advancedOrder: [
      'vertex_ai_auth_type',
      'vertex_ai_api_key',
      'vertex_ai_credentials_json',
      'vertex_ai_location',
      'timeout',
      'proxy',
      'gm_resp_image_modal',
      'gm_native_search',
      'gm_native_coderunner',
      'gm_url_context',
      'gm_safety_settings',
      'gm_thinking_config'
    ]
  }
} as const

function getFieldConfig(provider?: string) {
  return PROVIDER_FIELD_CONFIG[provider as keyof typeof PROVIDER_FIELD_CONFIG] ?? PROVIDER_FIELD_CONFIG.default
}
```

Then `basicSourceConfig` and `advancedSourceConfig` become simpler and provider‑agnostic at the call sites:

```ts
const basicSourceConfig = computed(() => {
  if (!editableProviderSource.value) return null

  const { basic } = getFieldConfig(editableProviderSource.value.provider)
  const basicObj: Record<string, any> = {}

  basic.forEach((field) => {
    Object.defineProperty(basicObj, field, {
      get() {
        return editableProviderSource.value![field]
      },
      set(val) {
        editableProviderSource.value![field] = val
      },
      enumerable: true
    })
  })

  return basicObj
})

const advancedSourceConfig = computed(() => {
  if (!editableProviderSource.value) return null

  const cfg = getFieldConfig(editableProviderSource.value.provider)
  const excluded = new Set(cfg.hidden)
  const advanced: Record<string, any> = {}

  const sourceKeys = Object.keys(editableProviderSource.value)
  const keys = cfg.advancedOrder
    ? [
        ...cfg.advancedOrder.filter((field) => sourceKeys.includes(field)),
        ...sourceKeys.filter((field) => !cfg.advancedOrder!.includes(field))
      ]
    : sourceKeys

  for (const key of keys) {
    Object.defineProperty(advanced, key, {
      get() {
        return editableProviderSource.value![key]
      },
      set(val) {
        editableProviderSource.value![key] = val
      },
      enumerable: !excluded.has(key)
    })
  }

  return advanced
})
```

This removes scattered Vertex‑specific sets/arrays and a nested conditional in `advancedSourceConfig`, while preserving:

- Vertex’s different basic fields (`['id', 'api_base']`)
- Vertex’s hidden fields
- Vertex’s advanced field ordering

Adding new providers or tweaking Vertex just means updating `PROVIDER_FIELD_CONFIG` in one place.

### 2. Isolate Vertex default normalization

`ensureProviderSourceDefaults` is starting to accumulate provider logic. You can keep the new Vertex defaults but move them into a dedicated helper to decouple the generic function from the provider specifics:

```ts
function applyVertexDefaults(source: any) {
  if (source.provider !== 'google-vertex-ai') return

  if (!source.vertex_ai_auth_type || source.vertex_ai_auth_type === 'service_account') {
    source.vertex_ai_auth_type = 'json'
  }
  if (source.vertex_ai_api_key === undefined) {
    source.vertex_ai_api_key =
      source.vertex_ai_auth_type === 'api_key' ? (source.key || []) : []
  }
  if (source.vertex_ai_credentials_json === undefined) {
    source.vertex_ai_credentials_json = ''
  }
  if (!source.vertex_ai_location) {
    source.vertex_ai_location = 'global'
  }
}

function ensureProviderSourceDefaults(source: any) {
  if (!source || typeof source !== 'object') {
    return source
  }

  if (source.provider === 'ollama' && source.ollama_disable_thinking === undefined) {
    source.ollama_disable_thinking = false
  }

  applyVertexDefaults(source)

  return source
}
```

This keeps the composable’s main flow cleaner and makes it obvious where provider‑specific normalization lives, without reverting any functionality.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread astrbot/core/provider/sources/gemini_source.py Outdated
)


class SuppressNonTextPartsWarning(logging.Filter):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider moving most Vertex AI–specific URL, auth, and model-handling logic into shared helpers in vertex_ai.py so GeminiSource stays a thin, generic consumer of those utilities.

You can simplify this by pushing more of the Vertex-specific decisions into vertex_ai.py and turning GeminiSource into a thin consumer of those helpers. That will reduce duplication and keep the Vertex logic in one place.

1. Centralize base URL / SDK HttpOptions logic

_get_vertex_ai_sdk_base_url re-implements base URL normalization that’s already conceptually handled in vertex_ai.py. Instead, add a helper there that returns the SDK-ready base URL (and api_version when needed) and call it from _init_client.

vertex_ai.py (new helper):

# vertex_ai.py

def build_vertex_ai_http_options(provider_config, timeout_seconds: int) -> dict:
    """
    Return kwargs for types.HttpOptions for Vertex AI:
    {
        "base_url": ...,
        "timeout": ...,
        "api_version": ...
    }
    """
    configured_base_url = str(provider_config.get("api_base") or "").strip().rstrip("/")
    location = normalize_vertex_ai_location(provider_config.get("vertex_ai_location"))

    base_url = _normalize_base_url(configured_base_url, location)
    # _normalize_base_url can encapsulate logic now in _get_vertex_ai_sdk_base_url

    kwargs: dict = {
        "base_url": base_url,
        "timeout": timeout_seconds * 1000,
        "api_version": VERTEX_AI_API_VERSION,
    }
    return kwargs

GeminiSource._init_client (simplified):

def _init_client(self) -> None:
    proxy = self.provider_config.get("proxy", "")
    is_vertex_ai = self._is_vertex_ai_config()

    if is_vertex_ai:
        http_options_kwargs = build_vertex_ai_http_options(
            self.provider_config, self.timeout
        )
    else:
        http_options_kwargs = {
            "base_url": self.api_base,
            "timeout": self.timeout * 1000,
        }

    http_options = types.HttpOptions(**http_options_kwargs)

    async_client_kwargs: dict = {"timeout": self.timeout}
    base_url = http_options_kwargs.get("base_url")
    if base_url:
        async_client_kwargs["base_url"] = base_url
    ...
    self._http_client = httpx.AsyncClient(**async_client_kwargs)
    http_options.httpx_async_client = self._http_client

    client_kwargs = self._get_genai_client_kwargs(http_options)
    self.client = genai.Client(**client_kwargs).aio

This removes _get_vertex_ai_sdk_base_url entirely and delegates base URL behavior to the dedicated module.

2. Move auth decision tree into vertex_ai.py

_get_genai_client_kwargs, _load_vertex_ai_service_account_credentials, _resolve_vertex_ai_project_id_required, _has_vertex_ai_key_json, _ensure_vertex_ai_api_key_for_generation all mix auth-type resolution with SDK wiring. A single central helper in vertex_ai.py can return a ready-to-use client_kwargs dict, so GeminiSource only needs to decide “Vertex vs non-Vertex”.

vertex_ai.py (new helper):

# vertex_ai.py

def build_vertex_ai_client_kwargs(provider_config, http_options) -> dict:
    auth_type = normalize_vertex_ai_auth_type(
        provider_config.get("vertex_ai_auth_type")
    )

    if auth_type == VERTEX_AI_API_KEY_AUTH and not extract_vertex_ai_credentials_json(provider_config).strip():
        # API-key only, no service account JSON
        return {
            "vertexai": True,
            "http_options": http_options,
            "api_key": provider_config.get("api_key") or "",
        }

    if auth_type in {VERTEX_AI_API_KEY_AUTH, VERTEX_AI_JSON_AUTH}:
        credentials = load_vertex_ai_service_account_credentials(provider_config)
        project = resolve_vertex_ai_project_id(provider_config)
        if not project:
            raise ValueError("Vertex AI project id is required for service account auth.")
        return {
            "vertexai": True,
            "http_options": http_options,
            "credentials": credentials,
            "project": project,
            "location": normalize_vertex_ai_location(
                provider_config.get("vertex_ai_location")
            ),
        }

    raise ValueError(
        "Vertex AI key format must be api_key or json for Google GenAI provider."
    )

(You can move the existing service-account loading logic into load_vertex_ai_service_account_credentials here instead of duplicating it in GeminiSource.)

GeminiSource._get_genai_client_kwargs (simplified):

def _get_genai_client_kwargs(self, http_options: types.HttpOptions) -> dict:
    if not self._is_vertex_ai_config():
        return {
            "api_key": self.chosen_api_key,
            "http_options": http_options,
        }

    return build_vertex_ai_client_kwargs(self.provider_config, http_options)

GeminiSource._ensure_vertex_ai_api_key_for_generation (can be removed or wrapped):

If you still want the guard for pure API-key mode, you can implement that in vertex_ai.py (e.g., ensure_vertex_ai_api_key_for_generation(provider_config, chosen_api_key)) and call that from _query / _query_stream, instead of re-implementing the same decision logic locally.

3. Centralize model-name normalization & listing

Model name logic is now split across _get_request_model_name, _normalize_model_name, to_vertex_ai_genai_model_name, and Vertex publisher listing. You can delegate most of this into vertex_ai.py too.

vertex_ai.py (new helpers):

# vertex_ai.py

def normalize_vertex_ai_sdk_model_name(name: str) -> str:
    # logic currently in _normalize_model_name for Vertex
    name = name.strip()
    if VERTEX_AI_MODEL_NAME_SEPARATOR in name:
        name = name.rsplit(VERTEX_AI_MODEL_NAME_SEPARATOR, 1)[1]
    elif name.startswith("models/"):
        name = name.removeprefix("models/")
    if not name.startswith("google/"):
        name = f"google/{name}"
    return name

def normalize_gemini_model_name(name: str) -> str:
    return name.strip().replace("models/", "")

GeminiSource:

def _get_request_model_name(self, model: str) -> str:
    if self._is_vertex_ai_config():
        return to_vertex_ai_genai_model_name(model)
    return model

def _normalize_model_name(self, name: str) -> str:
    if self._is_vertex_ai_config():
        return normalize_vertex_ai_sdk_model_name(name)
    return normalize_gemini_model_name(name)

For listing models, consider moving the Vertex-specific get_models logic into one helper (e.g., fetch_vertex_ai_models(provider_config, http_client)) that encapsulates the token refresh, URL building, and header construction. GeminiSource.get_models then just branches and forwards:

async def get_models(self):
    if self._is_vertex_ai_config():
        return await fetch_vertex_ai_models(self.provider_config, self._http_client)

    try:
        models = await self.client.models.list()
        model_ids = [
            self._normalize_model_name(m.name)
            for m in models
            if m.supported_actions
            and "generateContent" in m.supported_actions
            and m.name
        ]
        return normalize_and_dedupe_strings(model_ids)
    except APIError as e:
        raise Exception(f"获取模型列表失败: {e.message}")

This way, GeminiSource remains responsible for high-level decisions (Vertex vs non-Vertex, streaming vs non-streaming) and delegates endpoint/auth/model-name details to vertex_ai.py, reducing the cognitive load and duplication without altering functionality.

const basicSourceConfig = computed(() => {
if (!editableProviderSource.value) return null

const fields = ['id', 'key', 'api_base']
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider centralizing provider-specific field configuration and defaulting logic into dedicated helpers so the composable stays provider-agnostic and easier to extend.

You can keep the new Vertex behavior but reduce complexity by centralizing the provider‑specific rules into a single configuration object instead of scattering provider === 'google-vertex-ai' checks.

1. Centralize basic/advanced field config per provider

Right now basicSourceConfig, advancedSourceConfig, vertexHiddenFields, vertexAdvancedSourceFields, and the Vertex‑specific branching are separate. You can keep behavior identical by introducing a config map:

const PROVIDER_FIELD_CONFIG = {
  default: {
    basic: ['id', 'key', 'api_base'],
    hidden: new Set(['id', 'key', 'api_base', 'enable', 'type', 'provider_type', 'provider']),
    advancedOrder: null as string[] | null
  },
  'google-vertex-ai': {
    basic: ['id', 'api_base'],
    hidden: new Set([
      'id',
      'key',
      'api_base',
      'enable',
      'type',
      'provider_type',
      'provider',
      'vertex_ai_project_id',
      'vertex_ai_credentials_path'
    ]),
    advancedOrder: [
      'vertex_ai_auth_type',
      'vertex_ai_api_key',
      'vertex_ai_credentials_json',
      'vertex_ai_location',
      'timeout',
      'proxy',
      'gm_resp_image_modal',
      'gm_native_search',
      'gm_native_coderunner',
      'gm_url_context',
      'gm_safety_settings',
      'gm_thinking_config'
    ]
  }
} as const

function getFieldConfig(provider?: string) {
  return PROVIDER_FIELD_CONFIG[provider as keyof typeof PROVIDER_FIELD_CONFIG] ?? PROVIDER_FIELD_CONFIG.default
}

Then basicSourceConfig and advancedSourceConfig become simpler and provider‑agnostic at the call sites:

const basicSourceConfig = computed(() => {
  if (!editableProviderSource.value) return null

  const { basic } = getFieldConfig(editableProviderSource.value.provider)
  const basicObj: Record<string, any> = {}

  basic.forEach((field) => {
    Object.defineProperty(basicObj, field, {
      get() {
        return editableProviderSource.value![field]
      },
      set(val) {
        editableProviderSource.value![field] = val
      },
      enumerable: true
    })
  })

  return basicObj
})

const advancedSourceConfig = computed(() => {
  if (!editableProviderSource.value) return null

  const cfg = getFieldConfig(editableProviderSource.value.provider)
  const excluded = new Set(cfg.hidden)
  const advanced: Record<string, any> = {}

  const sourceKeys = Object.keys(editableProviderSource.value)
  const keys = cfg.advancedOrder
    ? [
        ...cfg.advancedOrder.filter((field) => sourceKeys.includes(field)),
        ...sourceKeys.filter((field) => !cfg.advancedOrder!.includes(field))
      ]
    : sourceKeys

  for (const key of keys) {
    Object.defineProperty(advanced, key, {
      get() {
        return editableProviderSource.value![key]
      },
      set(val) {
        editableProviderSource.value![key] = val
      },
      enumerable: !excluded.has(key)
    })
  }

  return advanced
})

This removes scattered Vertex‑specific sets/arrays and a nested conditional in advancedSourceConfig, while preserving:

  • Vertex’s different basic fields (['id', 'api_base'])
  • Vertex’s hidden fields
  • Vertex’s advanced field ordering

Adding new providers or tweaking Vertex just means updating PROVIDER_FIELD_CONFIG in one place.

2. Isolate Vertex default normalization

ensureProviderSourceDefaults is starting to accumulate provider logic. You can keep the new Vertex defaults but move them into a dedicated helper to decouple the generic function from the provider specifics:

function applyVertexDefaults(source: any) {
  if (source.provider !== 'google-vertex-ai') return

  if (!source.vertex_ai_auth_type || source.vertex_ai_auth_type === 'service_account') {
    source.vertex_ai_auth_type = 'json'
  }
  if (source.vertex_ai_api_key === undefined) {
    source.vertex_ai_api_key =
      source.vertex_ai_auth_type === 'api_key' ? (source.key || []) : []
  }
  if (source.vertex_ai_credentials_json === undefined) {
    source.vertex_ai_credentials_json = ''
  }
  if (!source.vertex_ai_location) {
    source.vertex_ai_location = 'global'
  }
}

function ensureProviderSourceDefaults(source: any) {
  if (!source || typeof source !== 'object') {
    return source
  }

  if (source.provider === 'ollama' && source.ollama_disable_thinking === undefined) {
    source.ollama_disable_thinking = false
  }

  applyVertexDefaults(source)

  return source
}

This keeps the composable’s main flow cleaner and makes it obvious where provider‑specific normalization lives, without reverting any functionality.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces support for Google Vertex AI as a provider, integrating it with the native Gemini API. It adds configuration templates, localization files, frontend dashboard updates, and backend normalization logic to handle both API key and service account JSON authentication methods. Additionally, comprehensive unit tests are introduced to verify the integration. The review feedback highlights a critical issue in gemini_source.py where a synchronous blocking call to credentials.refresh is made within an asynchronous method, potentially blocking the main event loop. It is recommended to run this call in a separate thread using asyncio.to_thread.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +1119 to +1122
if not getattr(credentials, "valid", False) or not getattr(
credentials, "token", None
):
credentials.refresh(make_vertex_ai_refresh_request(self.provider_config))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

_get_vertex_ai_models 方法中,credentials.refresh(...) 是一个同步的阻塞网络请求。在单线程的 asyncio 事件循环中,直接调用它会阻塞整个事件循环,导致机器人的其他协程和任务(例如其他用户的消息处理)全部暂停。

建议使用 asyncio.to_thread 将该同步阻塞调用放到单独的线程中执行,以保持事件循环的流畅性。

Suggested change
if not getattr(credentials, "valid", False) or not getattr(
credentials, "token", None
):
credentials.refresh(make_vertex_ai_refresh_request(self.provider_config))
if not getattr(credentials, "valid", False) or not getattr(
credentials, "token", None
):
await asyncio.to_thread(
credentials.refresh,
make_vertex_ai_refresh_request(self.provider_config),
)

- Run the Vertex AI OAuth token refresh off the event loop
  (asyncio.to_thread) in _get_vertex_ai_models so model discovery does
  not block the async runtime on slow networks.
- Log a warning when a proxy is configured but the 'requests' package is
  unavailable for token refresh, instead of silently ignoring the proxy.
- Document that a pasted service-account JSON takes precedence over
  api_key auth in _get_genai_client_kwargs.
- Add a test covering the credential-refresh path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@starmiaoa
Copy link
Copy Markdown
Author

感谢 @sourcery-ai@gemini-code-assist 的 review 🙏 已在 edc58c9 处理:

已修复 / Fixed

  • 阻塞事件循环(两个 bot 都指出的关键点):_get_vertex_ai_modelscredentials.refresh() 是同步网络 I/O,已改为 await asyncio.to_thread(...) 放到线程执行,避免拉取模型列表时卡住 async 运行时。
  • make_vertex_ai_refresh_request:配置了 proxy 但缺少 requests 时改为 logger.warning 提示,不再静默忽略 proxy(注:已新增 google-auth[requests] 依赖,正常情况下 requests 都会存在,这里只是兜底)。
  • _get_genai_client_kwargs:补充注释,明确"粘贴了服务账号 JSON 时优先走 service-account(即使 auth_type=api_key)"的优先级。
  • ✅ 新增测试 test_vertex_ai_get_models_refreshes_expired_credentials 覆盖凭据刷新路径(共 26 passed,ruff check / format 通过)。

暂缓 / Deferred(建议后续单独 PR)

  • sourcery 提议把 base URL / auth / model-name 逻辑进一步下沉到 vertex_ai.py,以及前端用 PROVIDER_FIELD_CONFIG 配置表统一各 provider 的差异 —— 这些是合理的复杂度优化,但属于不改变行为的较大范围重构。为了让本 PR 聚焦、降低回归风险,倾向于先合入当前实现,后续再单独优化。如果维护者希望在本 PR 内一并处理,我可以跟进。

@gemini-code-assist
Copy link
Copy Markdown
Contributor

感谢你的详细反馈和快速响应。你所做的改进(特别是将 credentials.refresh() 移至线程中以避免阻塞事件循环,以及对 proxy 和鉴权优先级的处理)非常到位,这极大地提升了该 provider 的健壮性。

关于你提到的重构建议:我完全同意你的看法。为了保持 PR 的聚焦度并降低回归风险,将这些非行为变更的架构优化(如进一步下沉逻辑到 vertex_ai.py 或统一前端配置表)放在后续的独立 PR 中处理是更稳妥的工程实践。目前的实现已经能够满足核心需求,且测试覆盖良好,非常适合合入。

感谢你为 AstrBot 贡献这个高质量的 Vertex AI 集成!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:provider The bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner. size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]新增 Google Vertex Ai 模型供应商预设 [Bug/Feature] 支持Google Vertex AI的API接入

1 participant