fix(gateway): pass user apiKey to createGateway when fetching models (#14605)

### What this PR does

Before this PR:

Clicking "Fetch Models" for the Vercel AI Gateway provider always fails
silently — the UI shows no models and the log records `"Failed to fetch
models from provider"` with `error: {}`. This happens because `models()`
called `gateway.getAvailableModels()` on a global unauthenticated
singleton (imported from `ai`) that never received the user's configured
API key.

After this PR:

`models()` calls `createGateway({ apiKey })` to create a per-request
instance with the user's configured API key, so the request actually
reaches Vercel's API. The error log in `fetchModels` is also fixed to
serialize the error as a string instead of `{}`.

Fixes #14591

### Why we need it and why it was done in this way

The `gateway` singleton exported by `@ai-sdk/gateway` is created with no
options (`createGatewayProvider()`), so `getGatewayAuthToken` finds
neither an API key nor a Vercel OIDC token and throws a
`GatewayAuthenticationError` before any network request is made.

The fix uses `createGateway({ apiKey: this.actualProvider.apiKey })` —
the same factory function the SDK exposes — to construct a fresh
instance per call. No new abstractions are introduced.

The following alternatives were considered:
- Caching the gateway instance on the provider instance: unnecessary
complexity for a simple per-call fix.

### Breaking changes

None.

### Special notes for your reviewer

The fix can be reproduced locally without a real Vercel key: configure
the provider with any fake key, click "Fetch Models", and observe the
log change from `error: {}` (unauthenticated) to `401 无效的令牌` (request
reached Vercel). Unit tests cover the key-passing behavior.

### Checklist

This checklist is not enforcing, but it's a reminder of items that could
be relevant to every PR.
Approvers are expected to review this list.

- [x] PR: The PR description is expressive enough and will help future
contributors
- [x] Code: [Write code that humans can
understand](https://en.wikiquote.org/wiki/Martin_Fowler#code-for-humans)
and [Keep it simple](https://en.wikipedia.org/wiki/KISS_principle)
- [x] Refactor: You have [left the code cleaner than you found it (Boy
Scout
Rule)](https://learning.oreilly.com/library/view/97-things-every/9780596809515/ch08.html)
- [ ] Upgrade: Impact of this change on upgrade flows was considered and
addressed if required
- [ ] Documentation: A [user-guide update](https://docs.cherry-ai.com)
was considered and is present (link) or not required. Check this only
when the PR introduces or changes a user-facing feature or behavior.
- [x] Self-review: I have reviewed my own code before requesting review
from others

### Release note

```release-note
Fixed Vercel AI Gateway model list fetch failing due to missing API key authentication.
```

Signed-off-by: raymond <13162938362@163.com>
This commit is contained in:
Qin Lingguang
2026-04-26 23:12:23 +08:00
committed by GitHub
parent 26d877e0fa
commit 9a213eaa4d
3 changed files with 90 additions and 3 deletions

View File

@@ -16,7 +16,7 @@ import {
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
import { getLowerBaseModelName } from '@renderer/utils'
import { buildClaudeCodeSystemModelMessage } from '@shared/anthropic'
import { gateway } from 'ai'
import { createGateway } from 'ai'
import AiSdkToChunkAdapter from './chunk/AiSdkToChunkAdapter'
import { buildPlugins } from './plugins/PluginBuilder'
@@ -331,7 +331,7 @@ export default class AiProvider {
public async models(): Promise<Model[]> {
// Gateway provider 使用 AI SDK 的 gateway API
if (this.actualProvider.id === SystemProviderIds.gateway) {
const gatewayModels = (await gateway.getAvailableModels()).models
const gatewayModels = (await createGateway({ apiKey: this.actualProvider.apiKey }).getAvailableModels()).models
return normalizeGatewayModels(this.actualProvider, gatewayModels)
}

View File

@@ -0,0 +1,87 @@
import type { GatewayLanguageModelEntry } from '@ai-sdk/gateway'
import type { Provider } from '@renderer/types'
import { SystemProviderIds } from '@renderer/types'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockGetAvailableModels = vi.fn()
const mockCreateGateway = vi.fn()
vi.mock('ai', async (importOriginal) => {
const actual = (await importOriginal()) as any
return {
...actual,
createGateway: (opts: any) => {
mockCreateGateway(opts)
return { getAvailableModels: mockGetAvailableModels }
}
}
})
vi.mock('@renderer/aiCore/provider/providerConfig', async (importOriginal) => {
const actual = (await importOriginal()) as any
return {
...actual,
adaptProvider: ({ provider }: { provider: Provider }) => provider,
providerToAiSdkConfig: vi.fn()
}
})
const makeGatewayProvider = (apiKey: string): Provider =>
({
id: SystemProviderIds.gateway,
name: 'Vercel AI Gateway',
type: 'gateway',
apiKey,
apiHost: 'https://ai-gateway.vercel.sh/v1/ai',
models: [],
isSystem: true,
enabled: true
}) as unknown as Provider
describe('AiProvider.models() — gateway provider', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('passes the configured apiKey to createGateway', async () => {
const { default: AiProvider } = await import('@renderer/aiCore/AiProvider')
mockGetAvailableModels.mockResolvedValue({ models: [] })
const provider = makeGatewayProvider('my-real-api-key')
const ai = new AiProvider(provider)
await ai.models()
expect(mockCreateGateway).toHaveBeenCalledWith({ apiKey: 'my-real-api-key' })
})
it('returns normalized models on success', async () => {
const { default: AiProvider } = await import('@renderer/aiCore/AiProvider')
const fakeEntries: GatewayLanguageModelEntry[] = [
{ id: 'openai/gpt-4o', name: 'GPT-4o', description: 'OpenAI GPT-4o' } as GatewayLanguageModelEntry,
{ id: 'anthropic/claude-3-5-sonnet', name: 'Claude 3.5 Sonnet' } as GatewayLanguageModelEntry
]
mockGetAvailableModels.mockResolvedValue({ models: fakeEntries })
const provider = makeGatewayProvider('valid-key')
const ai = new AiProvider(provider)
const models = await ai.models()
expect(models).toHaveLength(2)
expect(models[0].id).toBe('openai/gpt-4o')
expect(models[0].provider).toBe(SystemProviderIds.gateway)
expect(models[1].id).toBe('anthropic/claude-3-5-sonnet')
})
it('propagates error when getAvailableModels fails', async () => {
const { default: AiProvider } = await import('@renderer/aiCore/AiProvider')
mockGetAvailableModels.mockRejectedValue(new Error('401 Invalid Token'))
const provider = makeGatewayProvider('bad-key')
const ai = new AiProvider(provider)
await expect(ai.models()).rejects.toThrow('401 Invalid Token')
})
})

View File

@@ -789,7 +789,7 @@ export async function fetchModels(provider: Provider): Promise<Model[]> {
logger.error('Failed to fetch models from provider', {
providerId: provider.id,
providerName: provider.name,
error: error as Error
error: error instanceof Error ? error.message : String(error)
})
return []
}