feat(vertex): fetch live publisher models#1817
Conversation
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Greptile SummaryThis PR adds live Vertex AI publisher model fetching for
Confidence Score: 3/5The channel creation and routing paths are unaffected; the regression is confined to Fetch Models for existing gemini_vertex channels that already have stored GCP credentials. When a user edits an existing gemini_vertex channel and changes the service account JSON, clicking Fetch Models will silently use the old stored credentials rather than the new ones — stored channel GCP credentials unconditionally overwrite the request-supplied credentials, unlike all other credential types which respect the request value when non-empty. The frontend confirmed passes channelID alongside the new apiKey for every edit-mode fetch. internal/server/biz/model_fetcher.go — specifically the credential resolution block inside the input.ChannelID != nil branch and its interaction with the gcpCreds already set from the API key. Important Files Changed
Sequence DiagramsequenceDiagram
participant FE as Frontend (Edit Dialog)
participant GQL as GraphQL / FetchModels
participant MF as ModelFetcher.FetchModels
participant DB as Channel DB
participant Google as Google Token API
participant Vertex as Vertex AI Publisher Models
FE->>GQL: "{ channelType, baseURL, apiKey: JSON{region,projectID,jsonData}, channelID }"
GQL->>MF: "FetchModelsInput{...}"
MF->>MF: "gcpCredentialsFromFetchAPIKey(apiKey) -> gcpCreds (from request)"
alt ChannelID provided
MF->>DB: Channel.Get(channelID)
DB-->>MF: ch (with ch.Credentials.GCP)
Note over MF: if ch.Credentials.GCP != nil, gcpCreds = ch.Credentials.GCP (overwrites request creds)
end
alt gcpCreds.JSONData present
MF->>Google: CredentialsFromJSON + TokenSource.Token()
Google-->>MF: access_token (string, captured once)
loop up to 50 pages
MF->>Vertex: "GET /v1beta1/publishers/google/models?pageToken=..."
Vertex-->>MF: "{ publisherModels, nextPageToken }"
end
MF-->>GQL: "{ models }"
else no GCP credentials
MF->>MF: fetchGeminiVertexModels() static fallback list
MF-->>GQL: "{ models }"
end
GQL-->>FE: FetchModelsPayload
Reviews (1): Last reviewed commit: "feat(frontend): collect Vertex GCP crede..." | Re-trigger Greptile |
| if ch.Credentials.GCP != nil { | ||
| gcpCreds = ch.Credentials.GCP | ||
| if gcpCreds.Region == "" { | ||
| gcpCreds.Region = gcpRegionFromVertexBaseURL(input.BaseURL) | ||
| } | ||
| } |
There was a problem hiding this comment.
GCP credentials from request silently overridden by stored channel credentials
When editing an existing channel, the frontend sends both channelID and the newly entered GCP credentials as apiKey. The backend extracts gcpCreds from the request's apiKey first (lines 295–298), but then unconditionally overwrites it with the stored channel credentials at line 317 whenever ch.Credentials.GCP != nil. This is asymmetric with how regular apiKey is handled — the request's apiKey is only replaced when it's empty (line 323).
The confirmed call path: handleFetchModels sends channelID: isEdit ? currentRow?.id : undefined alongside the new GCP JSON encoded in apiKey. If the channel already has stored GCP credentials, every "Fetch Models" click during an edit will use the old stored credentials, silently ignoring the new JSON the user just entered.
| creds, err := google.CredentialsFromJSON(ctx, []byte(gcpCreds.JSONData), vertexOAuthScope) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to parse GCP credentials: %w", err) | ||
| } | ||
|
|
||
| token, err := creds.TokenSource.Token() | ||
| if err != nil { | ||
| return nil, fmt.Errorf("failed to get GCP access token: %w", err) | ||
| } |
There was a problem hiding this comment.
OAuth token captured once, bypasses automatic refresh for paginated requests
creds.TokenSource.Token() is called once and the raw AccessToken string is extracted, then passed to fetchGeminiVertexPublisherModelsWithToken for use across all pages. While Google's oauth2.TokenSource handles caching and refresh internally, extracting the raw string bypasses that mechanism. If listing models requires many pages (up to maxPages = 50) and the token is within seconds of its ~1-hour expiry when the first call is made, subsequent pages will receive 401 responses with no retry or refresh path.
| func TestBuildGeminiVertexPublisherModelsURL(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| baseURL string | ||
| region string | ||
| want string | ||
| }{ | ||
| { | ||
| name: "global base URL stays global", | ||
| baseURL: "https://aiplatform.googleapis.com/v1", | ||
| region: "global", | ||
| want: "https://aiplatform.googleapis.com/v1beta1/publishers/google/models", | ||
| }, | ||
| { | ||
| name: "regional Vertex base URL follows GCP region", | ||
| baseURL: "https://aiplatform.googleapis.com", | ||
| region: "us-central1", | ||
| want: "https://us-central1-aiplatform.googleapis.com/v1beta1/publishers/google/models", | ||
| }, | ||
| { | ||
| name: "custom gateway base URL is preserved", | ||
| baseURL: "https://gateway.example.com/google/v1beta1", | ||
| region: "us-central1", | ||
| want: "https://gateway.example.com/google/v1beta1/publishers/google/models", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| if got := buildGeminiVertexPublisherModelsURL(tt.baseURL, tt.region); got != tt.want { | ||
| t.Fatalf("buildGeminiVertexPublisherModelsURL() = %q, want %q", got, tt.want) | ||
| } | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Missing test case for non-versioned custom gateway URL
TestBuildGeminiVertexPublisherModelsURL covers aiplatform URLs and a versioned custom gateway (/v1beta1), but not a custom gateway URL without a version suffix (e.g., https://gateway.example.com/proxy). In that case !isAiplatformEndpoint && !strings.HasSuffix(...) falls through to the final return which appends /v1beta1/publishers/google/models. Adding a test case here would confirm the intended behavior and guard against regressions.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
变更内容
gemini_vertex增加 Vertex AI publisher model 在线拉取路径:有 GCP service account JSON 时调用v1beta1/publishers/google/models。验证结论
key=路径,返回API keys are not supported by this API,因此 API key 不能用于获取线上 publisher 模型列表。aiplatform.googleapis.com,线上请求停在403 API disabled。已验证
go test ./internal/server/biz -run 'Test(BuildGeminiVertexPublisherModelsURL|FetchGeminiVertexPublisherModelsWithToken|FetchModelsGeminiPagination)$'pnpm exec eslint src/features/channels/components/channels-action-dialog.tsx src/features/channels/data/schema.tspnpm exec prettier --check src/features/channels/components/channels-action-dialog.tsx src/features/channels/data/schema.ts src/locales/en/channels.json src/locales/zh-CN/channels.jsonGIT_MASTER=1 git diff --check备注
全量
pnpm exec tsc -b已运行,失败在仓库既有 TypeScript 错误,未涉及本次改动文件。