mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
fix(provider): honor base_url for google-genai and vertexai providers (#1269)
* fix(provider): honor base_url for google-genai and vertexai providers The google-genai and vertexai provider types silently ignored a configured base_url and always hit generativelanguage.googleapis.com (e.g. a Gemini- compatible proxy URL + key could not be used). Plumb the endpoint through to the @google/genai SDK via httpOptions.baseUrl: - kosong: add baseUrl to GoogleGenAIOptions and inject it into the client's httpOptions alongside the existing headers (the SDK merges headers and overrides the base host). - agent-core: forward provider.baseUrl in the google-genai and vertexai branches, with GOOGLE_GEMINI_BASE_URL / GOOGLE_VERTEX_BASE_URL env fallback. vertexai keeps deriving location from an aiplatform host. - docs: document base_url for both providers, noting the host root only must be given because the SDK appends the API version itself. Covered by unit tests asserting the URL reaches the kosong config and the SDK client's httpOptions. * fix(provider): use the effective base_url for vertex location detection The vertexai branch forwarded the endpoint from config `base_url` OR the GOOGLE_VERTEX_BASE_URL env fallback, but service-account detection (`hasVertexAIServiceEnv` / `vertexAILocation`) still derived the region from `provider.baseUrl` only. Supplying the regional endpoint via the env fallback (with a project but no explicit GOOGLE_CLOUD_LOCATION) therefore left location undefined and silently downgraded Vertex ADC to API-key Gemini routing. Resolve the effective base URL once and use it for both forwarding and location derivation, so the env fallback behaves exactly like `base_url`. Add a changeset for the kosong + agent-core patch release.
This commit is contained in:
parent
ffe41ac752
commit
bf35f63c5d
7 changed files with 274 additions and 16 deletions
7
.changeset/google-genai-base-url.md
Normal file
7
.changeset/google-genai-base-url.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@moonshot-ai/kosong": patch
|
||||
"@moonshot-ai/agent-core": patch
|
||||
"kimi-code-docs": patch
|
||||
---
|
||||
|
||||
Honor `base_url` for the `google-genai` and `vertexai` providers. A configured base URL was previously ignored and requests always went to `generativelanguage.googleapis.com`; it is now forwarded to the Google GenAI SDK (with `GOOGLE_GEMINI_BASE_URL` / `GOOGLE_VERTEX_BASE_URL` env fallbacks), so Gemini-compatible proxies and gateways can be used. Give the host root only — the SDK appends the API version segment itself.
|
||||
|
|
@ -119,6 +119,17 @@ type = "google-genai"
|
|||
api_key = "xxxxx"
|
||||
```
|
||||
|
||||
To route through a Gemini-compatible proxy or gateway, set `base_url` (or the `GOOGLE_GEMINI_BASE_URL` env var); when omitted, the SDK default `https://generativelanguage.googleapis.com` is used.
|
||||
|
||||
> Give the **host root only**. The Google GenAI SDK appends the API version and path itself (e.g. `/v1beta/models/<model>:generateContent`), so a trailing `/v1beta` would produce a doubled `/v1beta/v1beta/…`.
|
||||
|
||||
```toml
|
||||
[providers.gemini]
|
||||
type = "google-genai"
|
||||
api_key = "xxxxx"
|
||||
base_url = "https://your-gateway.example"
|
||||
```
|
||||
|
||||
## `vertexai`
|
||||
|
||||
Shares the same implementation as `google-genai`; setting `type = "vertexai"` switches to the Vertex AI access path.
|
||||
|
|
@ -139,6 +150,8 @@ gcloud auth application-default login # one-time authentication
|
|||
kimi
|
||||
```
|
||||
|
||||
To route Vertex requests through a custom (e.g. proxied) endpoint, set `base_url` (or the `GOOGLE_VERTEX_BASE_URL` env var); when omitted, the SDK default regional `*-aiplatform.googleapis.com` host is used. As with `google-genai`, give the host root only — the SDK appends `/v1beta1/publishers/google/models/…` itself.
|
||||
|
||||
## OAuth and credential injection
|
||||
|
||||
The Kimi Code managed service uses OAuth rather than static API keys. After running `/login`, the built-in authentication toolchain automatically writes and refreshes credentials — no manual configuration is needed in `config.toml` for this.
|
||||
|
|
|
|||
|
|
@ -119,6 +119,17 @@ type = "google-genai"
|
|||
api_key = "xxxxx"
|
||||
```
|
||||
|
||||
如需经由兼容 Gemini 协议的代理/网关访问,可设置 `base_url`(或 `GOOGLE_GEMINI_BASE_URL` 环境变量);不填时使用 SDK 默认地址 `https://generativelanguage.googleapis.com`。
|
||||
|
||||
> 只填**主机根地址**。Google GenAI SDK 会自行追加 API 版本与路径(如 `/v1beta/models/<model>:generateContent`),所以结尾带 `/v1beta` 会导致路径重复成 `/v1beta/v1beta/…`。
|
||||
|
||||
```toml
|
||||
[providers.gemini]
|
||||
type = "google-genai"
|
||||
api_key = "xxxxx"
|
||||
base_url = "https://your-gateway.example"
|
||||
```
|
||||
|
||||
## `vertexai`
|
||||
|
||||
与 `google-genai` 共用实现,`type = "vertexai"` 时切换到 Vertex AI 访问路径。
|
||||
|
|
@ -139,6 +150,8 @@ gcloud auth application-default login # 一次性完成认证
|
|||
kimi
|
||||
```
|
||||
|
||||
如需让 Vertex 请求走自定义(如代理)端点,可设置 `base_url`(或 `GOOGLE_VERTEX_BASE_URL` 环境变量);不填时使用 SDK 默认的区域化 `*-aiplatform.googleapis.com` 地址。与 `google-genai` 一样,只填主机根地址——SDK 会自行追加 `/v1beta1/publishers/google/models/…`。
|
||||
|
||||
## OAuth 与凭证注入
|
||||
|
||||
Kimi Code 托管服务使用 OAuth 而非静态 API 密钥。运行 `/login` 后,内置的认证工具链会自动写入并刷新凭证,`config.toml` 里无需手动配置这部分内容。
|
||||
|
|
|
|||
|
|
@ -309,6 +309,7 @@ function toKosongProviderConfig(
|
|||
return {
|
||||
type: 'google-genai',
|
||||
model,
|
||||
baseUrl: providerValue(provider.baseUrl, provider.env, 'GOOGLE_GEMINI_BASE_URL'),
|
||||
apiKey: providerApiKey(provider),
|
||||
...defaultHeadersField({
|
||||
...envCustomHeaders,
|
||||
|
|
@ -329,14 +330,21 @@ function toKosongProviderConfig(
|
|||
}),
|
||||
};
|
||||
case 'vertexai': {
|
||||
const useServiceAccount = hasVertexAIServiceEnv(provider);
|
||||
// Resolve the effective endpoint once (config `base_url` or the
|
||||
// GOOGLE_VERTEX_BASE_URL env fallback) and use it for BOTH forwarding and
|
||||
// location detection, so the env fallback behaves exactly like
|
||||
// `base_url` — including deriving the region from an
|
||||
// `*-aiplatform.googleapis.com` host for the service-account path.
|
||||
const baseUrl = providerValue(provider.baseUrl, provider.env, 'GOOGLE_VERTEX_BASE_URL');
|
||||
const useServiceAccount = hasVertexAIServiceEnv(provider, baseUrl);
|
||||
return {
|
||||
type: 'vertexai',
|
||||
model,
|
||||
vertexai: useServiceAccount,
|
||||
baseUrl,
|
||||
apiKey: useServiceAccount ? undefined : providerApiKey(provider),
|
||||
project: vertexAIProject(provider),
|
||||
location: vertexAILocation(provider),
|
||||
location: vertexAILocation(provider, baseUrl),
|
||||
...defaultHeadersField({
|
||||
...envCustomHeaders,
|
||||
...kimiUserAgentHeader(kimiRequestHeaders),
|
||||
|
|
@ -404,19 +412,19 @@ function providerApiKey(provider: ProviderConfig): string | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
function hasVertexAIServiceEnv(provider: ProviderConfig): boolean {
|
||||
return vertexAIProject(provider) !== undefined && vertexAILocation(provider) !== undefined;
|
||||
function hasVertexAIServiceEnv(provider: ProviderConfig, baseUrl: string | undefined): boolean {
|
||||
return vertexAIProject(provider) !== undefined && vertexAILocation(provider, baseUrl) !== undefined;
|
||||
}
|
||||
|
||||
function vertexAIProject(provider: ProviderConfig): string | undefined {
|
||||
return envValue(provider.env, 'GOOGLE_CLOUD_PROJECT');
|
||||
}
|
||||
|
||||
function vertexAILocation(provider: ProviderConfig): string | undefined {
|
||||
return (
|
||||
envValue(provider.env, 'GOOGLE_CLOUD_LOCATION') ??
|
||||
locationFromVertexAIBaseUrl(provider.baseUrl)
|
||||
);
|
||||
function vertexAILocation(
|
||||
provider: ProviderConfig,
|
||||
baseUrl: string | undefined,
|
||||
): string | undefined {
|
||||
return envValue(provider.env, 'GOOGLE_CLOUD_LOCATION') ?? locationFromVertexAIBaseUrl(baseUrl);
|
||||
}
|
||||
|
||||
function providerValue(
|
||||
|
|
|
|||
|
|
@ -845,6 +845,139 @@ describe('resolveThinkingEffort', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('google base URL forwarding', () => {
|
||||
it('forwards base_url to the google-genai provider config', () => {
|
||||
const resolved = resolveRuntimeProvider({
|
||||
config: {
|
||||
defaultModel: 'gemini',
|
||||
providers: {
|
||||
gemini: {
|
||||
type: 'google-genai',
|
||||
apiKey: 'g-key',
|
||||
baseUrl: 'https://qianxun.example/v1beta',
|
||||
},
|
||||
},
|
||||
models: {
|
||||
gemini: { provider: 'gemini', model: 'gemini-2.5-pro', maxContextSize: 1_000_000 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.provider).toMatchObject({
|
||||
type: 'google-genai',
|
||||
model: 'gemini-2.5-pro',
|
||||
baseUrl: 'https://qianxun.example/v1beta',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads GOOGLE_GEMINI_BASE_URL from provider env as a fallback', () => {
|
||||
const resolved = resolveRuntimeProvider({
|
||||
config: {
|
||||
defaultModel: 'gemini',
|
||||
providers: {
|
||||
gemini: {
|
||||
type: 'google-genai',
|
||||
apiKey: 'g-key',
|
||||
env: { GOOGLE_GEMINI_BASE_URL: 'https://env.example/v1beta' },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
gemini: { provider: 'gemini', model: 'gemini-2.5-pro', maxContextSize: 1_000_000 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.provider).toMatchObject({
|
||||
type: 'google-genai',
|
||||
baseUrl: 'https://env.example/v1beta',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a custom proxy base_url to the vertexai provider config', () => {
|
||||
const resolved = resolveRuntimeProvider({
|
||||
config: {
|
||||
defaultModel: 'gemini',
|
||||
providers: {
|
||||
vertex: {
|
||||
type: 'vertexai',
|
||||
apiKey: 'v-key',
|
||||
baseUrl: 'https://qianxun.example/vertex',
|
||||
},
|
||||
},
|
||||
models: {
|
||||
gemini: { provider: 'vertex', model: 'gemini-1.5-pro', maxContextSize: 1_000_000 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.provider).toMatchObject({
|
||||
type: 'vertexai',
|
||||
model: 'gemini-1.5-pro',
|
||||
baseUrl: 'https://qianxun.example/vertex',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards base_url to vertexai while still deriving location from an aiplatform host', () => {
|
||||
// Backward compatibility: an aiplatform host must keep populating `location`
|
||||
// (existing GCP behavior) while the base URL is now also forwarded so the
|
||||
// SDK targets the configured endpoint verbatim.
|
||||
const resolved = resolveRuntimeProvider({
|
||||
config: {
|
||||
defaultModel: 'gemini',
|
||||
providers: {
|
||||
vertex: {
|
||||
type: 'vertexai',
|
||||
apiKey: 'v-key',
|
||||
baseUrl: 'https://us-central1-aiplatform.googleapis.com',
|
||||
},
|
||||
},
|
||||
models: {
|
||||
gemini: { provider: 'vertex', model: 'gemini-1.5-pro', maxContextSize: 1_000_000 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.provider).toMatchObject({
|
||||
type: 'vertexai',
|
||||
baseUrl: 'https://us-central1-aiplatform.googleapis.com',
|
||||
location: 'us-central1',
|
||||
});
|
||||
});
|
||||
|
||||
it('derives vertex location from the GOOGLE_VERTEX_BASE_URL env fallback so ADC mode is selected', () => {
|
||||
// The env fallback must behave exactly like config `base_url`: when the
|
||||
// regional endpoint is supplied via GOOGLE_VERTEX_BASE_URL (with a project
|
||||
// but no explicit GOOGLE_CLOUD_LOCATION), location derivation must still see
|
||||
// it, so the provider resolves to service-account (ADC) mode rather than
|
||||
// silently downgrading to API-key Gemini routing.
|
||||
const resolved = resolveRuntimeProvider({
|
||||
config: {
|
||||
defaultModel: 'gemini',
|
||||
providers: {
|
||||
vertex: {
|
||||
type: 'vertexai',
|
||||
env: {
|
||||
GOOGLE_CLOUD_PROJECT: 'my-proj',
|
||||
GOOGLE_VERTEX_BASE_URL: 'https://us-central1-aiplatform.googleapis.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
gemini: { provider: 'vertex', model: 'gemini-1.5-pro', maxContextSize: 1_000_000 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.provider).toMatchObject({
|
||||
type: 'vertexai',
|
||||
vertexai: true,
|
||||
baseUrl: 'https://us-central1-aiplatform.googleapis.com',
|
||||
project: 'my-proj',
|
||||
location: 'us-central1',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-model protocol routing', () => {
|
||||
it('routes a protocol:anthropic model on a kimi provider through the anthropic transport with the REST base stripped of /v1', () => {
|
||||
const resolved = resolveRuntimeProvider({
|
||||
|
|
|
|||
|
|
@ -75,6 +75,14 @@ function normalizeGoogleGenAIFinishReason(raw: unknown): {
|
|||
export interface GoogleGenAIOptions {
|
||||
apiKey?: string | undefined;
|
||||
model: string;
|
||||
/**
|
||||
* Override the endpoint the SDK talks to (forwarded as
|
||||
* `httpOptions.baseUrl`). When unset, the SDK falls back to its default
|
||||
* (`generativelanguage.googleapis.com` for Gemini, the regional
|
||||
* `*-aiplatform.googleapis.com` for Vertex). Set this to route through a
|
||||
* Gemini-compatible proxy/gateway.
|
||||
*/
|
||||
baseUrl?: string | undefined;
|
||||
vertexai?: boolean | undefined;
|
||||
project?: string | undefined;
|
||||
location?: string | undefined;
|
||||
|
|
@ -682,6 +690,7 @@ export class GoogleGenAIChatProvider implements ChatProvider {
|
|||
private _vertexai: boolean;
|
||||
private _stream: boolean;
|
||||
private _apiKey: string | undefined;
|
||||
private _baseUrl: string | undefined;
|
||||
private _project: string | undefined;
|
||||
private _location: string | undefined;
|
||||
private _defaultHeaders: Record<string, string> | undefined;
|
||||
|
|
@ -695,6 +704,8 @@ export class GoogleGenAIChatProvider implements ChatProvider {
|
|||
|
||||
const apiKey = options.apiKey ?? process.env['GOOGLE_API_KEY'];
|
||||
this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey;
|
||||
this._baseUrl =
|
||||
options.baseUrl === undefined || options.baseUrl.length === 0 ? undefined : options.baseUrl;
|
||||
this._project = options.project;
|
||||
this._location = options.location;
|
||||
this._defaultHeaders = options.defaultHeaders;
|
||||
|
|
@ -704,6 +715,19 @@ export class GoogleGenAIChatProvider implements ChatProvider {
|
|||
}
|
||||
|
||||
private _buildClient(apiKey: string | undefined): GenAIClient {
|
||||
// The Google GenAI SDK reads the endpoint and headers from `httpOptions`,
|
||||
// deep-merging them over its defaults: a `baseUrl` here overrides the
|
||||
// default host (`generativelanguage.googleapis.com` / Vertex regional),
|
||||
// and a `User-Agent` overrides the SDK default (`google-genai-sdk/<ver> …`)
|
||||
// while preserving the other default headers (`x-goog-api-client`,
|
||||
// `Content-Type`). Build the object once so both can coexist.
|
||||
const httpOptions: { headers?: Record<string, string>; baseUrl?: string } = {};
|
||||
if (this._defaultHeaders !== undefined) {
|
||||
httpOptions.headers = this._defaultHeaders;
|
||||
}
|
||||
if (this._baseUrl !== undefined) {
|
||||
httpOptions.baseUrl = this._baseUrl;
|
||||
}
|
||||
return new GenAIClient({
|
||||
apiKey,
|
||||
...(this._vertexai
|
||||
|
|
@ -713,13 +737,7 @@ export class GoogleGenAIChatProvider implements ChatProvider {
|
|||
location: this._location,
|
||||
}
|
||||
: {}),
|
||||
// The Google GenAI SDK deep-merges `httpOptions.headers` into its
|
||||
// default request headers, so a `User-Agent` here overrides the SDK
|
||||
// default (`google-genai-sdk/<ver> …`) while preserving the other
|
||||
// defaults (`x-goog-api-client`, `Content-Type`).
|
||||
...(this._defaultHeaders !== undefined
|
||||
? { httpOptions: { headers: this._defaultHeaders } }
|
||||
: {}),
|
||||
...(Object.keys(httpOptions).length > 0 ? { httpOptions } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -888,6 +888,72 @@ describe('GoogleGenAIChatProvider', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('base URL forwarding', () => {
|
||||
// The @google/genai SDK exposes the effective endpoint through its internal
|
||||
// ApiClient. `getCustomBaseUrl()` returns exactly the `httpOptions.baseUrl`
|
||||
// handed to the client, so it is the most direct signal that a configured
|
||||
// base URL survived provider construction — the alternative being a silent
|
||||
// fallback to generativelanguage.googleapis.com.
|
||||
function customBaseUrl(provider: GoogleGenAIChatProvider): string | undefined {
|
||||
const client = (
|
||||
provider as unknown as {
|
||||
_client: { apiClient: { getCustomBaseUrl(): string | undefined } };
|
||||
}
|
||||
)._client;
|
||||
return client.apiClient.getCustomBaseUrl();
|
||||
}
|
||||
|
||||
it('forwards baseUrl to the Google GenAI SDK client', () => {
|
||||
const provider = new GoogleGenAIChatProvider({
|
||||
model: 'gemini-2.5-flash',
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://qianxun.example/v1beta',
|
||||
});
|
||||
expect(customBaseUrl(provider)).toBe('https://qianxun.example/v1beta');
|
||||
});
|
||||
|
||||
it('leaves the SDK default endpoint in place when no baseUrl is set', () => {
|
||||
const provider = new GoogleGenAIChatProvider({
|
||||
model: 'gemini-2.5-flash',
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
expect(customBaseUrl(provider)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forwards baseUrl and defaultHeaders together without dropping either', () => {
|
||||
const provider = new GoogleGenAIChatProvider({
|
||||
model: 'gemini-2.5-flash',
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://qianxun.example/v1beta',
|
||||
defaultHeaders: { 'User-Agent': 'kimi-code-cli/test' },
|
||||
});
|
||||
const client = (
|
||||
provider as unknown as {
|
||||
_client: {
|
||||
apiClient: {
|
||||
getCustomBaseUrl(): string | undefined;
|
||||
getHeaders(): Record<string, string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
)._client;
|
||||
expect(client.apiClient.getCustomBaseUrl()).toBe('https://qianxun.example/v1beta');
|
||||
expect(client.apiClient.getHeaders()).toMatchObject({
|
||||
'User-Agent': 'kimi-code-cli/test',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards baseUrl in vertexai mode', () => {
|
||||
const provider = new GoogleGenAIChatProvider({
|
||||
model: 'gemini-1.5-pro',
|
||||
apiKey: 'test-key',
|
||||
vertexai: true,
|
||||
baseUrl: 'https://qianxun.example/vertex',
|
||||
});
|
||||
expect(customBaseUrl(provider)).toBe('https://qianxun.example/vertex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('response parsing (non-stream)', () => {
|
||||
it('yields text from non-stream response', async () => {
|
||||
const provider = createProvider({ stream: false });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue