diff --git a/.changeset/google-genai-base-url.md b/.changeset/google-genai-base-url.md new file mode 100644 index 000000000..d96181504 --- /dev/null +++ b/.changeset/google-genai-base-url.md @@ -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. diff --git a/docs/en/configuration/providers.md b/docs/en/configuration/providers.md index 8fed5c4e1..de4292588 100644 --- a/docs/en/configuration/providers.md +++ b/docs/en/configuration/providers.md @@ -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/: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. diff --git a/docs/zh/configuration/providers.md b/docs/zh/configuration/providers.md index 41aae2736..b84351e9e 100644 --- a/docs/zh/configuration/providers.md +++ b/docs/zh/configuration/providers.md @@ -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/: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` 里无需手动配置这部分内容。 diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index e6132f259..9dc0f85b3 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -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( diff --git a/packages/agent-core/test/harness/runtime-provider.test.ts b/packages/agent-core/test/harness/runtime-provider.test.ts index e193c1ffa..6bcf2635d 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -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({ diff --git a/packages/kosong/src/providers/google-genai.ts b/packages/kosong/src/providers/google-genai.ts index 39fcc0db4..8c28cf215 100644 --- a/packages/kosong/src/providers/google-genai.ts +++ b/packages/kosong/src/providers/google-genai.ts @@ -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 | 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/ …`) + // while preserving the other default headers (`x-goog-api-client`, + // `Content-Type`). Build the object once so both can coexist. + const httpOptions: { headers?: Record; 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/ …`) while preserving the other - // defaults (`x-goog-api-client`, `Content-Type`). - ...(this._defaultHeaders !== undefined - ? { httpOptions: { headers: this._defaultHeaders } } - : {}), + ...(Object.keys(httpOptions).length > 0 ? { httpOptions } : {}), }); } diff --git a/packages/kosong/test/google-genai.test.ts b/packages/kosong/test/google-genai.test.ts index d8459c226..eecc48b3a 100644 --- a/packages/kosong/test/google-genai.test.ts +++ b/packages/kosong/test/google-genai.test.ts @@ -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; + }; + }; + } + )._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 });