diff --git a/.changeset/google-vertex-service-account.md b/.changeset/google-vertex-service-account.md new file mode 100644 index 0000000000..9ebb2f1ac7 --- /dev/null +++ b/.changeset/google-vertex-service-account.md @@ -0,0 +1,8 @@ +--- +"@moonshot-ai/acp-adapter": patch +"@moonshot-ai/agent-core": patch +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kosong": patch +--- + +feat: add GCP service account file support for google-vertex and google-vertex-anthropic diff --git a/docs/en/configuration/providers.md b/docs/en/configuration/providers.md index 43aeabb442..e8cf9ea3e3 100644 --- a/docs/en/configuration/providers.md +++ b/docs/en/configuration/providers.md @@ -130,27 +130,33 @@ api_key = "xxxxx" base_url = "https://your-gateway.example" ``` -## `vertexai` +## `google-vertex` / `vertexai` -Shares the same implementation as `google-genai`; setting `type = "vertexai"` switches to the Vertex AI access path. +Setting `type = "google-vertex"` (or `type = "vertexai"`) connects to Google Gemini models on Google Vertex AI. -Authentication follows the standard Google Cloud ADC flow (`gcloud auth application-default login` or a `GOOGLE_APPLICATION_CREDENTIALS` service account JSON) — this part is unrelated to Kimi Code. **The project ID and region must be written in the `[providers.vertexai.env]` sub-table** — simply `export GOOGLE_CLOUD_PROJECT` in the shell will not be read by the CLI. +Authentication supports a GCP service account JSON file (`service_account_file`, with `~` path expansion support), an environment variable (`GOOGLE_APPLICATION_CREDENTIALS`), or the standard ADC flow (`gcloud auth application-default login`). ```toml -[providers.vertexai] -type = "vertexai" - -[providers.vertexai.env] -GOOGLE_CLOUD_PROJECT = "my-gcp-project" -GOOGLE_CLOUD_LOCATION = "us-central1" +[providers.vertex] +type = "google-vertex" +service_account_file = "~/.secrets/my-service-account.json" +location = "us-central1" ``` -```sh -gcloud auth application-default login # one-time authentication -kimi -``` +When `service_account_file` is specified, Kimi Code automatically reads `project_id` from the service account JSON if `project` is omitted. + +To route Vertex requests through a custom (e.g. proxied) endpoint, set `base_url` (or the `GOOGLE_VERTEX_BASE_URL` env var). + +## `google-vertex-anthropic` -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. +Setting `type = "google-vertex-anthropic"` connects to Anthropic Claude models served on Google Vertex AI. + +```toml +[providers.vertex-claude] +type = "google-vertex-anthropic" +service_account_file = "~/.secrets/my-service-account.json" +location = "us-east5" +``` ## OAuth and credential injection diff --git a/docs/zh/configuration/providers.md b/docs/zh/configuration/providers.md index f97df28030..151dd1fd1d 100644 --- a/docs/zh/configuration/providers.md +++ b/docs/zh/configuration/providers.md @@ -130,27 +130,33 @@ api_key = "xxxxx" base_url = "https://your-gateway.example" ``` -## `vertexai` +## `google-vertex` / `vertexai` -与 `google-genai` 共用实现,`type = "vertexai"` 时切换到 Vertex AI 访问路径。 +设置 `type = "google-vertex"`(或 `type = "vertexai"`)连接 Google Vertex AI 上的 Gemini 模型。 -认证走 Google Cloud 标准 ADC 流程(`gcloud auth application-default login` 或 `GOOGLE_APPLICATION_CREDENTIALS` 服务账号 JSON),这部分与 Kimi Code 无关。**项目 ID 和区域必须写在 `[providers.vertexai.env]` 子表里**——直接在 shell 里 `export GOOGLE_CLOUD_PROJECT` 不会被 CLI 读取。 +认证支持 GCP 服务账号 JSON 文件(`service_account_file`,支持 `~` 路径展开)、环境变量(`GOOGLE_APPLICATION_CREDENTIALS`)或标准 ADC 流程(`gcloud auth application-default login`)。 ```toml -[providers.vertexai] -type = "vertexai" - -[providers.vertexai.env] -GOOGLE_CLOUD_PROJECT = "my-gcp-project" -GOOGLE_CLOUD_LOCATION = "us-central1" +[providers.vertex] +type = "google-vertex" +service_account_file = "~/.secrets/my-service-account.json" +location = "us-central1" ``` -```sh -gcloud auth application-default login # 一次性完成认证 -kimi -``` +指定 `service_account_file` 时,如果未填 `project`,Kimi Code 会自动从服务账号 JSON 中读取 `project_id`。 + +如需让 Vertex 请求走自定义(如代理)端点,可设置 `base_url`(或 `GOOGLE_VERTEX_BASE_URL` 环境变量)。 + +## `google-vertex-anthropic` -如需让 Vertex 请求走自定义(如代理)端点,可设置 `base_url`(或 `GOOGLE_VERTEX_BASE_URL` 环境变量);不填时使用 SDK 默认的区域化 `*-aiplatform.googleapis.com` 地址。与 `google-genai` 一样,只填主机根地址——SDK 会自行追加 `/v1beta1/publishers/google/models/…`。 +设置 `type = "google-vertex-anthropic"` 连接 Google Vertex AI 托管的 Anthropic Claude 模型。 + +```toml +[providers.vertex-claude] +type = "google-vertex-anthropic" +service_account_file = "~/.secrets/my-service-account.json" +location = "us-east5" +``` ## OAuth 与凭证注入 diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts index 6707fd4cae..de88d9cbb2 100644 --- a/packages/acp-adapter/src/server.ts +++ b/packages/acp-adapter/src/server.ts @@ -161,9 +161,15 @@ function providerHasNonOAuthCredentials(provider: ProviderConfig): boolean { case 'google-genai': return hasProviderValue(provider, 'GOOGLE_API_KEY'); case 'vertexai': + case 'google-vertex': + case 'google-vertex-anthropic': return ( hasProviderValue(provider, 'VERTEXAI_API_KEY') || hasEnvValue(provider, 'GOOGLE_API_KEY') || + hasEnvValue(provider, 'ANTHROPIC_API_KEY') || + nonEmptyString(provider.serviceAccountFile) !== undefined || + hasEnvValue(provider, 'GOOGLE_APPLICATION_CREDENTIALS') || + hasEnvValue(provider, 'SERVICE_ACCOUNT_FILE') || (hasEnvValue(provider, 'GOOGLE_CLOUD_PROJECT') && (hasEnvValue(provider, 'GOOGLE_CLOUD_LOCATION') || vertexAILocationFromBaseUrl(provider.baseUrl) !== undefined)) diff --git a/packages/acp-adapter/test/auth-gate.test.ts b/packages/acp-adapter/test/auth-gate.test.ts index a2fe7e5156..c183433695 100644 --- a/packages/acp-adapter/test/auth-gate.test.ts +++ b/packages/acp-adapter/test/auth-gate.test.ts @@ -245,6 +245,22 @@ describe('AcpServer auth gate', () => { expect(createCalls).toHaveLength(0); }); + it('accepts Vertex AI service-account file config', async () => { + const { harness, createCalls } = makeHarnessWithConfig( + configuredModelConfig({ + type: 'google-vertex', + serviceAccountFile: '~/.secrets/sa.json', + }), + ); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + startAcpServer(harness, agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await client.newSession({ cwd: '/tmp/vertexai', mcpServers: [] }); + expect(createCalls).toHaveLength(1); + }); + it('keeps the OAuth token short-circuit even when config loading fails', async () => { const createCalls: Array<{ id?: string; workDir: string }> = []; const harness = { diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index f36724a396..978191725a 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -308,6 +308,9 @@ merge_all_available_skills = true # default_model: string # type: string # api_key: string + # service_account_file: string + # project: string + # location: string # oauth: object # storage: "file" | "keyring" # key: string diff --git a/packages/agent-core-v2/package.json b/packages/agent-core-v2/package.json index cfd76230c2..b04c5c0762 100644 --- a/packages/agent-core-v2/package.json +++ b/packages/agent-core-v2/package.json @@ -58,6 +58,7 @@ "@antfu/utils": "^9.3.0", "@anthropic-ai/sdk": "^0.95.2", "@google/genai": "^1.49.0", + "google-auth-library": "^10.6.2", "@jsquash/webp": "^1.5.0", "@modelcontextprotocol/sdk": "^1.29.0", "@moonshot-ai/kimi-code-oauth": "workspace:^", diff --git a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts index b7870ca0ae..789a6e86f7 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts @@ -47,6 +47,9 @@ export const ProviderConfigSchema = z.object({ type: ProviderTypeSchema.optional(), apiKey: z.string().optional(), + serviceAccountFile: z.string().optional(), + project: z.string().optional(), + location: z.string().optional(), oauth: OAuthRefSchema.optional(), env: StringRecordSchema.optional(), source: z.record(z.string(), z.unknown()).optional(), diff --git a/packages/agent-core-v2/src/kosong/model/catalogService.ts b/packages/agent-core-v2/src/kosong/model/catalogService.ts index eec2673161..9c39c5e503 100644 --- a/packages/agent-core-v2/src/kosong/model/catalogService.ts +++ b/packages/agent-core-v2/src/kosong/model/catalogService.ts @@ -20,6 +20,7 @@ import { matchKnownAnthropicModelProfile, matchUnknownClaudeProfile, } from '../provider/bases/anthropic/anthropic-profile'; +import { expandHomePath, tryReadProjectIdFromServiceAccount } from '../provider/bases/vertex-utils'; import { IProviderService, type ProviderConfig, @@ -558,12 +559,23 @@ function buildProtocolProviderOptions( const options: MutableProtocolProviderOptions = {}; switch (protocol) { - case 'anthropic': + case 'anthropic': { if (model.maxOutputSize !== undefined) options.defaultMaxTokens = model.maxOutputSize; if (model.supportEfforts !== undefined) options.supportEfforts = model.supportEfforts; if (model.adaptiveThinking !== undefined) options.adaptiveThinking = model.adaptiveThinking; if (model.betaApi !== undefined) options.betaApi = model.betaApi; + const saFile = vertexAIServiceAccountFile(provider); + const project = vertexAIProject(provider, saFile); + const location = vertexAILocation(provider, baseUrl); + const isVertex = provider?.type === 'google-vertex-anthropic' || saFile !== undefined; + if (isVertex) { + options.vertexai = true; + if (saFile !== undefined) options.serviceAccountFile = saFile; + if (project !== undefined) options.project = project; + if (location !== undefined) options.location = location; + } break; + } case 'openai': { const reasoningKey = nonEmpty(model.reasoningKey); if (reasoningKey !== undefined) options.reasoningKey = reasoningKey; @@ -571,12 +583,19 @@ function buildProtocolProviderOptions( break; } case 'google-genai': { - const project = vertexAIProject(provider); + const saFile = vertexAIServiceAccountFile(provider); + const project = vertexAIProject(provider, saFile); const location = vertexAILocation(provider, baseUrl); - if (project !== undefined && location !== undefined) { + const isVertex = + provider?.type === 'vertexai' || + provider?.type === 'google-vertex' || + saFile !== undefined || + (project !== undefined && location !== undefined); + if (isVertex) { options.vertexai = true; - options.project = project; - options.location = location; + if (saFile !== undefined) options.serviceAccountFile = saFile; + if (project !== undefined) options.project = project; + if (location !== undefined) options.location = location; } break; } @@ -614,15 +633,37 @@ function profileForAttribution( return { profile: known, inferred: false }; } -function vertexAIProject(provider: ProviderConfig | undefined): string | undefined { - return envValue(provider?.env, 'GOOGLE_CLOUD_PROJECT'); +function vertexAIServiceAccountFile(provider: ProviderConfig | undefined): string | undefined { + const configured = + nonEmpty(provider?.serviceAccountFile) ?? + (typeof provider?.source?.['service_account_file'] === 'string' + ? nonEmpty(provider.source['service_account_file'] as string) + : undefined); + const rawPath = + configured ?? + envValue(provider?.env, 'GOOGLE_APPLICATION_CREDENTIALS') ?? + envValue(provider?.env, 'SERVICE_ACCOUNT_FILE'); + return expandHomePath(rawPath); +} + +function vertexAIProject(provider: ProviderConfig | undefined, saFile?: string): string | undefined { + const saPath = saFile ?? vertexAIServiceAccountFile(provider); + return ( + nonEmpty(provider?.project) ?? + envValue(provider?.env, 'GOOGLE_CLOUD_PROJECT') ?? + tryReadProjectIdFromServiceAccount(saPath) + ); } function vertexAILocation( provider: ProviderConfig | undefined, baseUrl: string | undefined, ): string | undefined { - return envValue(provider?.env, 'GOOGLE_CLOUD_LOCATION') ?? locationFromVertexAIBaseUrl(baseUrl); + return ( + nonEmpty(provider?.location) ?? + envValue(provider?.env, 'GOOGLE_CLOUD_LOCATION') ?? + locationFromVertexAIBaseUrl(baseUrl) + ); } function envValue(env: Record | undefined, key: string): string | undefined { diff --git a/packages/agent-core-v2/src/kosong/protocol/protocol.ts b/packages/agent-core-v2/src/kosong/protocol/protocol.ts index b0c38f90ac..27b3b40a28 100644 --- a/packages/agent-core-v2/src/kosong/protocol/protocol.ts +++ b/packages/agent-core-v2/src/kosong/protocol/protocol.ts @@ -27,6 +27,7 @@ export interface ProtocolProviderOptions { readonly vertexai?: boolean; readonly project?: string; readonly location?: string; + readonly serviceAccountFile?: string; } export interface ProtocolAdapterConfig { diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts index 64fc2d70b5..0a8ca46e26 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts @@ -22,6 +22,10 @@ registerProtocolBase({ adaptiveThinking: config.providerOptions?.adaptiveThinking, supportEfforts: config.providerOptions?.supportEfforts, betaApi: config.providerOptions?.betaApi, + vertexai: config.providerOptions?.vertexai, + project: config.providerOptions?.project, + location: config.providerOptions?.location, + serviceAccountFile: config.providerOptions?.serviceAccountFile, metadata: config.providerOptions?.metadata === undefined ? undefined diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts index 77576d00fc..dabbf0597c 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts @@ -49,6 +49,8 @@ import type { import type { Tool } from '#/kosong/contract/tool'; import type { TokenUsage } from '#/kosong/contract/usage'; +import { GoogleAuth } from 'google-auth-library'; + import { BUDGET_THINKING_EFFORTS, inferAnthropicModelProfile, @@ -60,6 +62,7 @@ import { import { mergeConsecutiveUserMessages } from '../merge-user-messages'; import { mergeRequestHeaders, resolveAuthBackedClient } from '../request-auth'; import { normalizeToolCallIdsForProvider, sanitizeToolCallId } from '../tool-call-id'; +import { expandHomePath, tryReadProjectIdFromServiceAccount } from '../vertex-utils'; function normalizeAnthropicStopReason(raw: string | null | undefined): { finishReason: FinishReason | null; @@ -122,6 +125,11 @@ export interface AnthropicOptions { supportEfforts?: readonly string[] | undefined; betaApi?: boolean | undefined; thinkingEffort?: ThinkingEffort | undefined; + vertexai?: boolean | undefined; + project?: string | undefined; + location?: string | undefined; + serviceAccountFile?: string | undefined; + googleAuthOptions?: Record | undefined; clientFactory?: (auth: ProviderRequestAuth) => Anthropic; hooks?: AnthropicHooks | undefined; } @@ -776,7 +784,7 @@ export class AnthropicChatProvider implements ChatProvider { private readonly _model: string; private readonly _stream: boolean; - private readonly _client: Anthropic | undefined; + private _client: Anthropic | undefined; private readonly _generationKwargs: AnthropicGenerationKwargs; private readonly _metadata: Record | undefined; private readonly _apiKey: string | undefined; @@ -789,6 +797,11 @@ export class AnthropicChatProvider implements ChatProvider { private readonly _thinkingEffort: ThinkingEffort | undefined; private readonly _explicitMaxTokens: boolean; private readonly _hooks: AnthropicHooks | undefined; + private readonly _vertexai: boolean; + private readonly _project: string | undefined; + private readonly _location: string | undefined; + private readonly _serviceAccountFile: string | undefined; + private readonly _googleAuthOptions: Record | undefined; constructor(options: AnthropicOptions) { this._model = options.model; @@ -799,12 +812,40 @@ export class AnthropicChatProvider implements ChatProvider { this._betaApi = options.betaApi ?? false; this._thinkingEffort = options.thinkingEffort; this._hooks = options.hooks; + this._vertexai = options.vertexai ?? false; this._apiKey = options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey; this._baseUrl = options.baseUrl; this._defaultHeaders = options.defaultHeaders; this._clientFactory = options.clientFactory; - this._client = this._apiKey === undefined ? undefined : this._buildClient(this._apiKey); + + const rawSaFile = + options.serviceAccountFile ?? + process.env['GOOGLE_APPLICATION_CREDENTIALS'] ?? + process.env['SERVICE_ACCOUNT_FILE']; + this._serviceAccountFile = expandHomePath(rawSaFile); + this._googleAuthOptions = + options.googleAuthOptions ?? + (this._serviceAccountFile !== undefined + ? { + keyFilename: this._serviceAccountFile, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + } + : undefined); + + this._project = + options.project ?? + (this._serviceAccountFile !== undefined + ? tryReadProjectIdFromServiceAccount(this._serviceAccountFile) + : undefined) ?? + process.env['GOOGLE_CLOUD_PROJECT']; + this._location = options.location ?? process.env['GOOGLE_CLOUD_LOCATION'] ?? 'us-central1'; + + this._client = this._vertexai + ? this._buildVertexClient() + : this._apiKey === undefined + ? undefined + : this._buildClient(this._apiKey); this._explicitMaxTokens = options.defaultMaxTokens !== undefined; this._generationKwargs = { max_tokens: options.defaultMaxTokens ?? resolveDefaultMaxTokens(options.model), @@ -1072,6 +1113,12 @@ export class AnthropicChatProvider implements ChatProvider { } private _createClient(auth: ProviderRequestAuth | undefined): Anthropic { + if (this._vertexai) { + if (this._client === undefined) { + this._client = this._buildVertexClient(); + } + return this._client; + } return resolveAuthBackedClient( { cachedClient: this._client, clientFactory: this._clientFactory }, auth, @@ -1116,6 +1163,128 @@ export class AnthropicChatProvider implements ChatProvider { return defaultHeaders; } + private _buildVertexClient(): Anthropic { + const googleAuth = new GoogleAuth( + this._googleAuthOptions ?? { + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + ); + + const project = this._project; + const location = this._location ?? 'us-central1'; + const customBaseUrl = this._baseUrl; + + const vertexFetch = async ( + url: string | URL | Request, + init?: RequestInit, + ): Promise => { + const authObj = googleAuth as { + getClient(): Promise<{ getAccessToken(): Promise<{ token?: string | null }> }>; + }; + const client = await authObj.getClient(); + const tokenResponse = await client.getAccessToken(); + const token = tokenResponse.token; + + let bodyObj: Record = {}; + if (init?.body && typeof init.body === 'string') { + try { + bodyObj = JSON.parse(init.body) as Record; + } catch { + bodyObj = {}; + } + } + + const isStream = bodyObj['stream'] === true; + const modelName = + (typeof bodyObj['model'] === 'string' ? bodyObj['model'] : this._model) || this._model; + delete bodyObj['model']; + delete bodyObj['stream']; + bodyObj['anthropic_version'] = 'vertex-2023-10-16'; + + const verb = isStream ? 'streamRawPredict' : 'rawPredict'; + + let targetUrl: string; + if (customBaseUrl && customBaseUrl.length > 0) { + const cleanBase = customBaseUrl.replace(/\/+$/, ''); + targetUrl = `${cleanBase}/v1/projects/${project}/locations/${location}/publishers/anthropic/models/${modelName}:${verb}`; + } else { + const host = + location === 'global' + ? 'aiplatform.googleapis.com' + : `${location}-aiplatform.googleapis.com`; + targetUrl = `https://${host}/v1/projects/${project}/locations/${location}/publishers/anthropic/models/${modelName}:${verb}`; + } + + const headers = new Headers(init?.headers); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + headers.set('Content-Type', 'application/json'); + + const newInit: RequestInit = { + ...init, + headers, + body: JSON.stringify(bodyObj), + }; + + const res = await fetch(targetUrl, newInit); + if (isStream && res.status === 200) { + const contentType = res.headers.get('content-type') ?? ''; + if (!contentType.includes('text/event-stream')) { + const json = (await res.json()) as Record; + const content = + (json['content'] as Array<{ type: string; text?: string; thinking?: string }>) || []; + const sseParts: string[] = [ + `event: message_start\ndata: ${JSON.stringify({ type: 'message_start', message: json })}\n\n`, + ]; + for (let idx = 0; idx < content.length; idx++) { + const c = content[idx]!; + if (c.type === 'thinking') { + sseParts.push( + `event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: idx, content_block: { type: 'thinking', thinking: '' } })}\n\n`, + ); + sseParts.push( + `event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: idx, delta: { type: 'thinking_delta', thinking: c.thinking || '' } })}\n\n`, + ); + sseParts.push( + `event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: idx })}\n\n`, + ); + } else { + sseParts.push( + `event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: idx, content_block: { type: 'text', text: '' } })}\n\n`, + ); + sseParts.push( + `event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: idx, delta: { type: 'text_delta', text: c.text || '' } })}\n\n`, + ); + sseParts.push( + `event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: idx })}\n\n`, + ); + } + } + sseParts.push( + `event: message_delta\ndata: ${JSON.stringify({ type: 'message_delta', delta: { stop_reason: json['stop_reason'] || 'end_turn', stop_sequence: json['stop_sequence'] }, usage: json['usage'] })}\n\n`, + ); + sseParts.push(`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`); + + return new Response(sseParts.join(''), { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + } + return res; + }; + + return new Anthropic({ + apiKey: this._apiKey ?? 'dummy-vertex-key', + authToken: null, + baseURL: null, + fetch: vertexFetch, + defaultHeaders: this._buildDefaultHeaders(this._apiKey ?? 'dummy-vertex-key'), + maxRetries: 0, + }); + } + private _buildClient(apiKey: string): Anthropic { return new Anthropic({ apiKey, diff --git a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.contrib.ts b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.contrib.ts index b9d2624690..7208e27e50 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.contrib.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.contrib.ts @@ -25,6 +25,7 @@ registerProtocolBase({ vertexai: config.providerOptions?.vertexai, project: config.providerOptions?.project, location: config.providerOptions?.location, + serviceAccountFile: config.providerOptions?.serviceAccountFile, }), }); }, diff --git a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts index b6bc1d5c0b..d930a956a6 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts +++ b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts @@ -22,6 +22,7 @@ import type { TokenUsage } from '#/kosong/contract/usage'; import { mergeConsecutiveUserMessages } from '../merge-user-messages'; import { requireProviderApiKey, resolveAuthBackedClient } from '../request-auth'; +import { expandHomePath, tryReadProjectIdFromServiceAccount } from '../vertex-utils'; function normalizeGoogleGenAIFinishReason(raw: unknown): { finishReason: FinishReason | null; @@ -69,6 +70,8 @@ export interface GoogleGenAIOptions { vertexai?: boolean | undefined; project?: string | undefined; location?: string | undefined; + serviceAccountFile?: string | undefined; + googleAuthOptions?: Record | undefined; stream?: boolean | undefined; thinkingEffort?: ThinkingEffort | undefined; defaultHeaders?: Record; @@ -100,16 +103,17 @@ interface GoogleTool { functionDeclarations: GoogleFunctionDeclaration[]; } -function toolToGoogleGenAI(tool: Tool): GoogleTool { - return { - functionDeclarations: [ - { +function toolsToGoogleGenAI(tools: Tool[]): GoogleTool[] { + if (tools.length === 0) return []; + return [ + { + functionDeclarations: tools.map((tool) => ({ name: tool.name, description: tool.description, parametersJsonSchema: tool.parameters, - }, - ], - }; + })), + }, + ]; } function applyResponseFormat( @@ -681,6 +685,8 @@ export class GoogleGenAIChatProvider implements ChatProvider { private readonly _baseUrl: string | undefined; private readonly _project: string | undefined; private readonly _location: string | undefined; + private readonly _serviceAccountFile: string | undefined; + private readonly _googleAuthOptions: Record | undefined; private readonly _thinkingEffort: ThinkingEffort | undefined; private readonly _defaultHeaders: Record | undefined; private readonly _clientFactory: ((auth: ProviderRequestAuth) => GenAIClient) | undefined; @@ -696,12 +702,34 @@ export class GoogleGenAIChatProvider implements ChatProvider { 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; + + const rawSaFile = + options.serviceAccountFile ?? + process.env['GOOGLE_APPLICATION_CREDENTIALS'] ?? + process.env['SERVICE_ACCOUNT_FILE']; + this._serviceAccountFile = expandHomePath(rawSaFile); + this._googleAuthOptions = + options.googleAuthOptions ?? + (this._serviceAccountFile !== undefined + ? { + keyFilename: this._serviceAccountFile, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + } + : undefined); + + this._project = + options.project ?? + (this._serviceAccountFile !== undefined + ? tryReadProjectIdFromServiceAccount(this._serviceAccountFile) + : undefined) ?? + process.env['GOOGLE_CLOUD_PROJECT']; + this._location = options.location ?? process.env['GOOGLE_CLOUD_LOCATION']; this._defaultHeaders = options.defaultHeaders; this._clientFactory = options.clientFactory; this._client = - this._vertexai || this._apiKey !== undefined ? this._buildClient(this._apiKey) : undefined; + this._vertexai || this._apiKey !== undefined || this._googleAuthOptions !== undefined + ? this._buildClient(this._apiKey) + : undefined; } private _buildClient(apiKey: string | undefined): GenAIClient { @@ -719,6 +747,9 @@ export class GoogleGenAIChatProvider implements ChatProvider { vertexai: true, project: this._project, location: this._location, + ...(this._googleAuthOptions !== undefined + ? { googleAuthOptions: this._googleAuthOptions } + : {}), } : {}), httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined, @@ -780,7 +811,7 @@ export class GoogleGenAIChatProvider implements ChatProvider { const config: Record = { ...kwargs, systemInstruction: systemPrompt, - ...(tools.length > 0 ? { tools: tools.map((t) => toolToGoogleGenAI(t)) } : {}), + ...(tools.length > 0 ? { tools: toolsToGoogleGenAI(tools) } : {}), }; applyResponseFormat(config, options?.responseFormat); diff --git a/packages/agent-core-v2/src/kosong/provider/bases/vertex-utils.ts b/packages/agent-core-v2/src/kosong/provider/bases/vertex-utils.ts new file mode 100644 index 0000000000..bc01f8d41d --- /dev/null +++ b/packages/agent-core-v2/src/kosong/provider/bases/vertex-utils.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export function expandHomePath(filePath: string | undefined): string | undefined { + if (filePath === undefined) return undefined; + const trimmed = filePath.trim(); + if (trimmed.length === 0) return undefined; + if (trimmed === '~') return homedir(); + if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return join(homedir(), trimmed.slice(2)); + } + return trimmed; +} + +export function tryReadProjectIdFromServiceAccount(filePath: string | undefined): string | undefined { + if (filePath === undefined) return undefined; + const expanded = expandHomePath(filePath); + if (expanded === undefined) return undefined; + try { + const content = readFileSync(expanded, 'utf8'); + const parsed = JSON.parse(content) as Record; + if (typeof parsed['project_id'] === 'string' && parsed['project_id'].length > 0) { + return parsed['project_id']; + } + } catch { + return undefined; + } + return undefined; +} diff --git a/packages/agent-core-v2/src/kosong/provider/provider.ts b/packages/agent-core-v2/src/kosong/provider/provider.ts index e6436506d2..8975b2095b 100644 --- a/packages/agent-core-v2/src/kosong/provider/provider.ts +++ b/packages/agent-core-v2/src/kosong/provider/provider.ts @@ -20,6 +20,9 @@ export interface ProviderConfig { type?: ProviderType; apiKey?: string; + serviceAccountFile?: string; + project?: string; + location?: string; oauth?: OAuthRef; env?: Record; source?: Record; diff --git a/packages/agent-core-v2/src/kosong/provider/providers/standard.contrib.ts b/packages/agent-core-v2/src/kosong/provider/providers/standard.contrib.ts index 6e075737fa..9789f76db0 100644 --- a/packages/agent-core-v2/src/kosong/provider/providers/standard.contrib.ts +++ b/packages/agent-core-v2/src/kosong/provider/providers/standard.contrib.ts @@ -29,3 +29,30 @@ registerProviderDefinition({ { endpoint: () => ({ apiKeyEnv: 'GOOGLE_API_KEY', baseUrlEnv: 'GOOGLE_GEMINI_BASE_URL' }) }, ], }); + +registerProviderDefinition({ + id: 'vertexai', + baseProtocol: 'google-genai', + traits: [ + { endpoint: () => ({ apiKeyEnv: 'VERTEXAI_API_KEY', baseUrlEnv: 'GOOGLE_VERTEX_BASE_URL' }) }, + { endpoint: () => ({ apiKeyEnv: 'GOOGLE_API_KEY', baseUrlEnv: 'GOOGLE_GEMINI_BASE_URL' }) }, + ], +}); + +registerProviderDefinition({ + id: 'google-vertex', + baseProtocol: 'google-genai', + traits: [ + { endpoint: () => ({ apiKeyEnv: 'VERTEXAI_API_KEY', baseUrlEnv: 'GOOGLE_VERTEX_BASE_URL' }) }, + { endpoint: () => ({ apiKeyEnv: 'GOOGLE_API_KEY', baseUrlEnv: 'GOOGLE_GEMINI_BASE_URL' }) }, + ], +}); + +registerProviderDefinition({ + id: 'google-vertex-anthropic', + baseProtocol: 'anthropic', + traits: [ + { endpoint: () => ({ apiKeyEnv: 'VERTEXAI_API_KEY', baseUrlEnv: 'GOOGLE_VERTEX_BASE_URL' }) }, + { endpoint: () => ({ apiKeyEnv: 'ANTHROPIC_API_KEY', baseUrlEnv: 'ANTHROPIC_BASE_URL' }) }, + ], +}); diff --git a/packages/agent-core-v2/test/kosong/model/catalog.test.ts b/packages/agent-core-v2/test/kosong/model/catalog.test.ts index 98a51a8797..5eeba4bbff 100644 --- a/packages/agent-core-v2/test/kosong/model/catalog.test.ts +++ b/packages/agent-core-v2/test/kosong/model/catalog.test.ts @@ -386,6 +386,43 @@ describe('Model assembly (pure data)', () => { } }); + it('enables google-vertex and google-vertex-anthropic through providerOptions with serviceAccountFile', () => { + const { host, catalog } = createHost({ + providers: { + vtx: { + type: 'google-vertex', + serviceAccountFile: '~/.secrets/sa.json', + location: 'us-central1', + }, + vtx_claude: { + type: 'google-vertex-anthropic', + serviceAccountFile: '~/.secrets/sa.json', + project: 'proj-claude', + location: 'us-east5', + }, + }, + models: { + v1: { provider: 'vtx', model: 'gemini-2.5-pro', maxContextSize: 1000 }, + v2: { provider: 'vtx_claude', model: 'claude-sonnet-4-6', maxContextSize: 1000 }, + }, + }); + try { + expect(catalog.get('v1').providerOptions).toMatchObject({ + vertexai: true, + serviceAccountFile: expect.stringContaining('.secrets/sa.json'), + location: 'us-central1', + }); + expect(catalog.get('v2').providerOptions).toMatchObject({ + vertexai: true, + serviceAccountFile: expect.stringContaining('.secrets/sa.json'), + project: 'proj-claude', + location: 'us-east5', + }); + } finally { + host.dispose(); + } + }); + it('supports flat models with an inline baseUrl (provider synthesized from the origin)', () => { const { host, catalog } = createHost({ models: { diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 62770fde97..63a7a13d50 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -10,6 +10,8 @@ export const ProviderTypeSchema = z.enum([ 'google-genai', 'openai_responses', 'vertexai', + 'google-vertex', + 'google-vertex-anthropic', ]); export type ProviderType = z.infer; @@ -29,6 +31,9 @@ export const ProviderConfigSchema = z.object({ apiKey: z.string().optional(), baseUrl: z.string().optional(), defaultModel: z.string().optional(), + serviceAccountFile: z.string().optional(), + project: z.string().optional(), + location: z.string().optional(), oauth: OAuthRefSchema.optional(), env: StringRecordSchema.optional(), customHeaders: StringRecordSchema.optional(), diff --git a/packages/agent-core/src/session/provider-manager.ts b/packages/agent-core/src/session/provider-manager.ts index 7fb313b461..a7ac97a6c5 100644 --- a/packages/agent-core/src/session/provider-manager.ts +++ b/packages/agent-core/src/session/provider-manager.ts @@ -3,7 +3,9 @@ import type { ProviderConfig as KosongProviderConfig, ModelCapability, ProviderR import { APIStatusError, classifyKimiQuotaError, + expandHomePath, getModelCapability, + tryReadProjectIdFromServiceAccount, UNKNOWN_CAPABILITY, } from '@moonshot-ai/kosong'; import { parseKimiCodeCustomHeaders } from '@moonshot-ai/kimi-code-oauth'; @@ -379,7 +381,8 @@ function toKosongProviderConfig( ...provider.customHeaders, }), }; - case 'vertexai': { + case 'vertexai': + case 'google-vertex': { // 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 @@ -387,14 +390,36 @@ function toKosongProviderConfig( // `*-aiplatform.googleapis.com` host for the service-account path. const baseUrl = modelBaseUrl ?? providerValue(provider.baseUrl, provider.env, 'GOOGLE_VERTEX_BASE_URL'); + const saFile = vertexAIServiceAccountFile(provider); const useServiceAccount = hasVertexAIServiceEnv(provider, baseUrl); return { - type: 'vertexai', + type: provider.type, model, vertexai: useServiceAccount, baseUrl, apiKey: useServiceAccount ? undefined : providerApiKey(provider), - project: vertexAIProject(provider), + serviceAccountFile: saFile, + project: vertexAIProject(provider, saFile), + location: vertexAILocation(provider, baseUrl), + ...defaultHeadersField({ + ...envCustomHeaders, + ...kimiUserAgentHeader(kimiRequestHeaders), + ...provider.customHeaders, + }), + }; + } + case 'google-vertex-anthropic': { + const baseUrl = + modelBaseUrl ?? providerValue(provider.baseUrl, provider.env, 'GOOGLE_VERTEX_BASE_URL'); + const saFile = vertexAIServiceAccountFile(provider); + return { + type: 'google-vertex-anthropic', + model, + vertexai: true, + baseUrl, + apiKey: providerApiKey(provider), + serviceAccountFile: saFile, + project: vertexAIProject(provider, saFile), location: vertexAILocation(provider, baseUrl), ...defaultHeadersField({ ...envCustomHeaders, @@ -448,10 +473,13 @@ function providerApiKey(provider: ProviderConfig): string | undefined { case 'google-genai': return providerValue(provider.apiKey, provider.env, 'GOOGLE_API_KEY'); case 'vertexai': + case 'google-vertex': + case 'google-vertex-anthropic': return ( nonEmptyString(provider.apiKey) ?? envValue(provider.env, 'VERTEXAI_API_KEY') ?? - envValue(provider.env, 'GOOGLE_API_KEY') + envValue(provider.env, 'GOOGLE_API_KEY') ?? + envValue(provider.env, 'ANTHROPIC_API_KEY') ); default: { const exhaustive: never = provider.type; @@ -463,19 +491,46 @@ function providerApiKey(provider: ProviderConfig): string | undefined { } } +function vertexAIServiceAccountFile(provider: ProviderConfig): string | undefined { + const configured = + nonEmptyString(provider.serviceAccountFile) ?? + (typeof provider.source?.['service_account_file'] === 'string' + ? nonEmptyString(provider.source['service_account_file'] as string) + : undefined); + const rawPath = + configured ?? + envValue(provider.env, 'GOOGLE_APPLICATION_CREDENTIALS') ?? + envValue(provider.env, 'SERVICE_ACCOUNT_FILE'); + return expandHomePath(rawPath); +} + function hasVertexAIServiceEnv(provider: ProviderConfig, baseUrl: string | undefined): boolean { - return vertexAIProject(provider) !== undefined && vertexAILocation(provider, baseUrl) !== undefined; + const saFile = vertexAIServiceAccountFile(provider); + return ( + saFile !== undefined || + (vertexAIProject(provider, saFile) !== undefined && + vertexAILocation(provider, baseUrl) !== undefined) + ); } -function vertexAIProject(provider: ProviderConfig): string | undefined { - return envValue(provider.env, 'GOOGLE_CLOUD_PROJECT'); +function vertexAIProject(provider: ProviderConfig, saFile?: string): string | undefined { + const saPath = saFile ?? vertexAIServiceAccountFile(provider); + return ( + nonEmptyString(provider.project) ?? + envValue(provider.env, 'GOOGLE_CLOUD_PROJECT') ?? + tryReadProjectIdFromServiceAccount(saPath) + ); } function vertexAILocation( provider: ProviderConfig, baseUrl: string | undefined, ): string | undefined { - return envValue(provider.env, 'GOOGLE_CLOUD_LOCATION') ?? locationFromVertexAIBaseUrl(baseUrl); + return ( + nonEmptyString(provider.location) ?? + 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 5f26a2d599..7e26f3201d 100644 --- a/packages/agent-core/test/harness/runtime-provider.test.ts +++ b/packages/agent-core/test/harness/runtime-provider.test.ts @@ -1236,6 +1236,58 @@ describe('google base URL forwarding', () => { location: 'us-central1', }); }); + + it('resolves google-vertex provider with serviceAccountFile and tilde expansion', () => { + const resolved = resolveRuntimeProvider({ + config: { + defaultModel: 'gemini', + providers: { + vtx: { + type: 'google-vertex', + serviceAccountFile: '~/.secrets/my-sa.json', + location: 'us-central1', + }, + }, + models: { + gemini: { provider: 'vtx', model: 'gemini-2.5-pro', maxContextSize: 1_000_000 }, + }, + }, + }); + + expect(resolved.provider).toMatchObject({ + type: 'google-vertex', + vertexai: true, + serviceAccountFile: expect.stringContaining('.secrets/my-sa.json'), + location: 'us-central1', + }); + }); + + it('resolves google-vertex-anthropic provider with serviceAccountFile', () => { + const resolved = resolveRuntimeProvider({ + config: { + defaultModel: 'claude', + providers: { + vtx_claude: { + type: 'google-vertex-anthropic', + serviceAccountFile: '~/.secrets/my-sa.json', + project: 'proj-123', + location: 'us-east5', + }, + }, + models: { + claude: { provider: 'vtx_claude', model: 'claude-sonnet-4-6', maxContextSize: 200_000 }, + }, + }, + }); + + expect(resolved.provider).toMatchObject({ + type: 'google-vertex-anthropic', + vertexai: true, + serviceAccountFile: expect.stringContaining('.secrets/my-sa.json'), + project: 'proj-123', + location: 'us-east5', + }); + }); }); describe('per-model protocol routing', () => { diff --git a/packages/kosong/package.json b/packages/kosong/package.json index 6ac04bc839..3f16ac781e 100644 --- a/packages/kosong/package.json +++ b/packages/kosong/package.json @@ -45,6 +45,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.95.2", "@google/genai": "^1.49.0", + "google-auth-library": "^10.6.2", "openai": "^6.34.0", "zod": "^4.3.6", "zod-to-json-schema": "^3.25.2" diff --git a/packages/kosong/src/catalog.ts b/packages/kosong/src/catalog.ts index 6c7c611c81..3e3ee959a7 100644 --- a/packages/kosong/src/catalog.ts +++ b/packages/kosong/src/catalog.ts @@ -101,6 +101,8 @@ const KNOWN_WIRE_TYPES = [ 'google-genai', 'openai_responses', 'vertexai', + 'google-vertex', + 'google-vertex-anthropic', ] as const satisfies readonly ProviderType[]; function isWireType(value: unknown): value is ProviderType { @@ -244,6 +246,10 @@ function inferDeclaredWireType(entry: CatalogProviderEntry): ProviderType | unde if (isWireType(entry.type)) return entry.type; const npm = (entry.npm ?? '').toLowerCase(); const id = (entry.id ?? '').toLowerCase(); + if (id === 'google-vertex-anthropic' || npm.includes('google-vertex/anthropic') || id.includes('vertex-anthropic')) { + return 'google-vertex-anthropic'; + } + if (id === 'google-vertex') return 'google-vertex'; if (npm.includes('anthropic') || id.includes('anthropic') || id.includes('claude')) { return 'anthropic'; } diff --git a/packages/kosong/src/index.ts b/packages/kosong/src/index.ts index cd0440637b..f56dac3c4f 100644 --- a/packages/kosong/src/index.ts +++ b/packages/kosong/src/index.ts @@ -27,6 +27,9 @@ export type { export * from './provider'; export { createProvider, getModelCapability } from './providers'; export type { ProviderConfig, ProviderType } from './providers'; +export { expandHomePath, tryReadProjectIdFromServiceAccount } from './providers/vertex-utils'; +export { GoogleGenAIChatProvider } from './providers/google-genai'; +export { AnthropicChatProvider } from './providers/anthropic'; // Kimi provider: exported so callers can narrow a `ChatProvider` to the Kimi // backend (instanceof) and apply Kimi-specific request params (generation // kwargs, `thinking.keep` extra body). diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index 5af85106e2..6f32768cf0 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -50,6 +50,7 @@ import { type AnthropicModelProfile, type AnthropicModelVersion, } from './anthropic-profile'; +import { GoogleAuth } from 'google-auth-library'; import { mergeConsecutiveUserMessages } from './merge-user-messages'; import { mergeRequestHeaders, resolveAuthBackedClient } from './request-auth'; import { @@ -57,6 +58,7 @@ import { sanitizeToolCallId, type ToolCallIdPolicy, } from './tool-call-id'; +import { expandHomePath, tryReadProjectIdFromServiceAccount } from './vertex-utils'; /** * Normalize an Anthropic `stop_reason` string to the unified @@ -121,6 +123,11 @@ export interface AnthropicOptions { * keeps the standard endpoint + header behavior. */ betaApi?: boolean | undefined; + vertexai?: boolean | undefined; + project?: string | undefined; + location?: string | undefined; + serviceAccountFile?: string | undefined; + googleAuthOptions?: Record | undefined; clientFactory?: (auth: ProviderRequestAuth) => Anthropic; /** * Vendor error classification, consulted by `convertAnthropicError` with @@ -934,6 +941,11 @@ export class AnthropicChatProvider implements ChatProvider { private readonly _convertErrorHook: ((error: unknown) => ChatProviderError | undefined) | undefined; private _betaApi: boolean; private _explicitMaxTokens: boolean; + private _vertexai: boolean; + private _project: string | undefined; + private _location: string | undefined; + private _serviceAccountFile: string | undefined; + private _googleAuthOptions: Record | undefined; constructor(options: AnthropicOptions) { this._model = options.model; @@ -944,12 +956,40 @@ export class AnthropicChatProvider implements ChatProvider { this._kimiThinking = options.kimiThinking ?? false; this._convertErrorHook = options.convertError; this._betaApi = options.betaApi ?? false; + this._vertexai = options.vertexai ?? false; this._apiKey = options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey; this._baseUrl = options.baseUrl; this._defaultHeaders = options.defaultHeaders; this._clientFactory = options.clientFactory; - this._client = this._apiKey === undefined ? undefined : this._buildClient(this._apiKey); + + const rawSaFile = + options.serviceAccountFile ?? + process.env['GOOGLE_APPLICATION_CREDENTIALS'] ?? + process.env['SERVICE_ACCOUNT_FILE']; + this._serviceAccountFile = expandHomePath(rawSaFile); + this._googleAuthOptions = + options.googleAuthOptions ?? + (this._serviceAccountFile !== undefined + ? { + keyFilename: this._serviceAccountFile, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + } + : undefined); + + this._project = + options.project ?? + (this._serviceAccountFile !== undefined + ? tryReadProjectIdFromServiceAccount(this._serviceAccountFile) + : undefined) ?? + process.env['GOOGLE_CLOUD_PROJECT']; + this._location = options.location ?? process.env['GOOGLE_CLOUD_LOCATION'] ?? 'us-central1'; + + this._client = this._vertexai + ? this._buildVertexClient() + : this._apiKey === undefined + ? undefined + : this._buildClient(this._apiKey); this._explicitMaxTokens = options.defaultMaxTokens !== undefined; this._generationKwargs = { max_tokens: options.defaultMaxTokens ?? resolveDefaultMaxTokens(options.model), @@ -1157,6 +1197,12 @@ export class AnthropicChatProvider implements ChatProvider { } private _createClient(auth: ProviderRequestAuth | undefined): Anthropic { + if (this._vertexai) { + if (this._client === undefined) { + this._client = this._buildVertexClient(); + } + return this._client; + } return resolveAuthBackedClient( { cachedClient: this._client, clientFactory: this._clientFactory }, auth, @@ -1210,6 +1256,127 @@ export class AnthropicChatProvider implements ChatProvider { // These `null`s — and the nulled headers in _buildDefaultHeaders — are NOT // redundant: removing them reintroduces credential leakage. Regression cover: // test/e2e/anthropic-adapter.test.ts. + private _buildVertexClient(): Anthropic { + const googleAuth = new GoogleAuth( + this._googleAuthOptions ?? { + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }, + ); + + const project = this._project; + const location = this._location ?? 'us-central1'; + const customBaseUrl = this._baseUrl; + + const vertexFetch = async ( + url: string | URL | Request, + init?: RequestInit, + ): Promise => { + const authObj = googleAuth as { + getClient(): Promise<{ getAccessToken(): Promise<{ token?: string | null }> }>; + }; + const client = await authObj.getClient(); + const tokenResponse = await client.getAccessToken(); + const token = tokenResponse.token; + + let bodyObj: Record = {}; + if (init?.body && typeof init.body === 'string') { + try { + bodyObj = JSON.parse(init.body) as Record; + } catch { + bodyObj = {}; + } + } + + const isStream = bodyObj['stream'] === true; + const modelName = + (typeof bodyObj['model'] === 'string' ? bodyObj['model'] : this._model) || this._model; + delete bodyObj['model']; + delete bodyObj['stream']; + bodyObj['anthropic_version'] = 'vertex-2023-10-16'; + + const verb = isStream ? 'streamRawPredict' : 'rawPredict'; + + let targetUrl: string; + if (customBaseUrl && customBaseUrl.length > 0) { + const cleanBase = customBaseUrl.replace(/\/+$/, ''); + targetUrl = `${cleanBase}/v1/projects/${project}/locations/${location}/publishers/anthropic/models/${modelName}:${verb}`; + } else { + const host = + location === 'global' + ? 'aiplatform.googleapis.com' + : `${location}-aiplatform.googleapis.com`; + targetUrl = `https://${host}/v1/projects/${project}/locations/${location}/publishers/anthropic/models/${modelName}:${verb}`; + } + + const headers = new Headers(init?.headers); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + headers.set('Content-Type', 'application/json'); + + const newInit: RequestInit = { + ...init, + headers, + body: JSON.stringify(bodyObj), + }; + + const res = await fetch(targetUrl, newInit); + if (isStream && res.status === 200) { + const contentType = res.headers.get('content-type') ?? ''; + if (!contentType.includes('text/event-stream')) { + const json = (await res.json()) as Record; + const content = + (json['content'] as Array<{ type: string; text?: string; thinking?: string }>) || []; + const sseParts: string[] = [ + `event: message_start\ndata: ${JSON.stringify({ type: 'message_start', message: json })}\n\n`, + ]; + for (let idx = 0; idx < content.length; idx++) { + const c = content[idx]!; + if (c.type === 'thinking') { + sseParts.push( + `event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: idx, content_block: { type: 'thinking', thinking: '' } })}\n\n`, + ); + sseParts.push( + `event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: idx, delta: { type: 'thinking_delta', thinking: c.thinking || '' } })}\n\n`, + ); + sseParts.push( + `event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: idx })}\n\n`, + ); + } else { + sseParts.push( + `event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: idx, content_block: { type: 'text', text: '' } })}\n\n`, + ); + sseParts.push( + `event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: idx, delta: { type: 'text_delta', text: c.text || '' } })}\n\n`, + ); + sseParts.push( + `event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: idx })}\n\n`, + ); + } + } + sseParts.push( + `event: message_delta\ndata: ${JSON.stringify({ type: 'message_delta', delta: { stop_reason: json['stop_reason'] || 'end_turn', stop_sequence: json['stop_sequence'] }, usage: json['usage'] })}\n\n`, + ); + sseParts.push(`event: message_stop\ndata: ${JSON.stringify({ type: 'message_stop' })}\n\n`); + + return new Response(sseParts.join(''), { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + } + } + return res; + }; + + return new Anthropic({ + apiKey: this._apiKey ?? 'dummy-vertex-key', + authToken: null, + baseURL: null, + fetch: vertexFetch, + defaultHeaders: this._buildDefaultHeaders(this._apiKey ?? 'dummy-vertex-key'), + }); + } + private _buildClient(apiKey: string): Anthropic { return new Anthropic({ apiKey, diff --git a/packages/kosong/src/providers/google-genai.ts b/packages/kosong/src/providers/google-genai.ts index 5015e0a4b6..59b2162e54 100644 --- a/packages/kosong/src/providers/google-genai.ts +++ b/packages/kosong/src/providers/google-genai.ts @@ -19,6 +19,7 @@ import type { Tool } from '#/tool'; import type { TokenUsage } from '#/usage'; import { ApiError as GoogleApiError, GoogleGenAI as GenAIClient } from '@google/genai'; import { mergeConsecutiveUserMessages } from './merge-user-messages'; +import { expandHomePath, tryReadProjectIdFromServiceAccount } from './vertex-utils'; import { requireProviderApiKey, resolveAuthBackedClient } from './request-auth'; @@ -88,6 +89,8 @@ export interface GoogleGenAIOptions { vertexai?: boolean | undefined; project?: string | undefined; location?: string | undefined; + serviceAccountFile?: string | undefined; + googleAuthOptions?: Record | undefined; stream?: boolean | undefined; defaultHeaders?: Record; clientFactory?: (auth: ProviderRequestAuth) => GenAIClient; @@ -117,16 +120,17 @@ interface GoogleTool { functionDeclarations: GoogleFunctionDeclaration[]; } -function toolToGoogleGenAI(tool: Tool): GoogleTool { - return { - functionDeclarations: [ - { +function toolsToGoogleGenAI(tools: Tool[]): GoogleTool[] { + if (tools.length === 0) return []; + return [ + { + functionDeclarations: tools.map((tool) => ({ name: tool.name, description: tool.description, parametersJsonSchema: tool.parameters, - }, - ], - }; + })), + }, + ]; } function applyResponseFormat( @@ -754,6 +758,8 @@ export class GoogleGenAIChatProvider implements ChatProvider { private _baseUrl: string | undefined; private _project: string | undefined; private _location: string | undefined; + private _serviceAccountFile: string | undefined; + private _googleAuthOptions: Record | undefined; private _defaultHeaders: Record | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => GenAIClient) | undefined; @@ -767,12 +773,34 @@ export class GoogleGenAIChatProvider implements ChatProvider { 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; + + const rawSaFile = + options.serviceAccountFile ?? + process.env['GOOGLE_APPLICATION_CREDENTIALS'] ?? + process.env['SERVICE_ACCOUNT_FILE']; + this._serviceAccountFile = expandHomePath(rawSaFile); + this._googleAuthOptions = + options.googleAuthOptions ?? + (this._serviceAccountFile !== undefined + ? { + keyFilename: this._serviceAccountFile, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + } + : undefined); + + this._project = + options.project ?? + (this._serviceAccountFile !== undefined + ? tryReadProjectIdFromServiceAccount(this._serviceAccountFile) + : undefined) ?? + process.env['GOOGLE_CLOUD_PROJECT']; + this._location = options.location ?? process.env['GOOGLE_CLOUD_LOCATION']; this._defaultHeaders = options.defaultHeaders; this._clientFactory = options.clientFactory; this._client = - this._vertexai || this._apiKey !== undefined ? this._buildClient(this._apiKey) : undefined; + this._vertexai || this._apiKey !== undefined || this._googleAuthOptions !== undefined + ? this._buildClient(this._apiKey) + : undefined; } private _buildClient(apiKey: string | undefined): GenAIClient { @@ -796,6 +824,9 @@ export class GoogleGenAIChatProvider implements ChatProvider { vertexai: true, project: this._project, location: this._location, + ...(this._googleAuthOptions !== undefined + ? { googleAuthOptions: this._googleAuthOptions } + : {}), } : {}), ...(Object.keys(httpOptions).length > 0 ? { httpOptions } : {}), @@ -863,7 +894,7 @@ export class GoogleGenAIChatProvider implements ChatProvider { const config: Record = { ...this._generationKwargs, systemInstruction: systemPrompt, - ...(tools.length > 0 ? { tools: tools.map((t) => toolToGoogleGenAI(t)) } : {}), + ...(tools.length > 0 ? { tools: toolsToGoogleGenAI(tools) } : {}), }; applyResponseFormat(config, options?.responseFormat); diff --git a/packages/kosong/src/providers/index.ts b/packages/kosong/src/providers/index.ts index d95e9c58e9..f7c253f980 100644 --- a/packages/kosong/src/providers/index.ts +++ b/packages/kosong/src/providers/index.ts @@ -18,7 +18,9 @@ export type ProviderConfig = | ({ type: 'kimi' } & KimiOptions) | ({ type: 'google-genai' } & GoogleGenAIOptions) | ({ type: 'openai_responses' } & OpenAIResponsesOptions) - | ({ type: 'vertexai' } & GoogleGenAIOptions); + | ({ type: 'vertexai' } & GoogleGenAIOptions) + | ({ type: 'google-vertex' } & GoogleGenAIOptions) + | ({ type: 'google-vertex-anthropic' } & AnthropicOptions); export type ProviderType = ProviderConfig['type']; @@ -36,6 +38,10 @@ export function createProvider(config: ProviderConfig): ChatProvider { return new OpenAIResponsesChatProvider(config); case 'vertexai': return new GoogleGenAIChatProvider(config); + case 'google-vertex': + return new GoogleGenAIChatProvider({ ...config, vertexai: true }); + case 'google-vertex-anthropic': + return new AnthropicChatProvider({ ...config, vertexai: true }); default: { const exhaustive: never = config; throw new Error(`Unknown provider type: ${String(exhaustive)}`); @@ -61,7 +67,10 @@ export function getModelCapability(wire: ProviderType, modelName: string): Model return getOpenAIResponsesModelCapability(modelName); case 'google-genai': case 'vertexai': + case 'google-vertex': return getGoogleGenAIModelCapability(modelName); + case 'google-vertex-anthropic': + return getAnthropicModelCapability(modelName); case 'kimi': return UNKNOWN_CAPABILITY; default: { diff --git a/packages/kosong/src/providers/vertex-utils.ts b/packages/kosong/src/providers/vertex-utils.ts new file mode 100644 index 0000000000..1063e94da4 --- /dev/null +++ b/packages/kosong/src/providers/vertex-utils.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +/** + * Expands a leading tilde (`~` or `~/`) in a file path to the user's home directory. + */ +export function expandHomePath(filePath: string | undefined): string | undefined { + if (filePath === undefined) return undefined; + const trimmed = filePath.trim(); + if (trimmed.length === 0) return undefined; + if (trimmed === '~') return homedir(); + if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return join(homedir(), trimmed.slice(2)); + } + return trimmed; +} + +/** + * Safely attempts to read the `project_id` field from a GCP service account JSON file. + */ +export function tryReadProjectIdFromServiceAccount(filePath: string | undefined): string | undefined { + if (filePath === undefined) return undefined; + const expanded = expandHomePath(filePath); + if (expanded === undefined) return undefined; + try { + const content = readFileSync(expanded, 'utf8'); + const parsed = JSON.parse(content) as Record; + if (typeof parsed['project_id'] === 'string' && parsed['project_id'].length > 0) { + return parsed['project_id']; + } + } catch { + // Ignore unreadable file or invalid JSON; SDK auth will handle invalid files + } + return undefined; +} diff --git a/packages/kosong/test/catalog.test.ts b/packages/kosong/test/catalog.test.ts index ace6298362..301c96669f 100644 --- a/packages/kosong/test/catalog.test.ts +++ b/packages/kosong/test/catalog.test.ts @@ -51,7 +51,7 @@ describe('resolveCatalogImport — wire resolution', () => { }); expect(resolveCatalogImport({ id: 'google-vertex' })).toMatchObject({ kind: 'ok', - wire: 'vertexai', + wire: 'google-vertex', }); }); @@ -125,15 +125,15 @@ describe('resolveCatalogImport — endpoint resolution', () => { }); expect( resolveCatalogImport({ id: 'google-vertex', npm: '@ai-sdk/google-vertex' }), - ).toMatchObject({ kind: 'ok', wire: 'vertexai' }); + ).toMatchObject({ kind: 'ok', wire: 'google-vertex' }); }); - it('needs a URL for non-official vendors without one', () => { + it('resolves google-vertex-anthropic without requiring a base URL prompt', () => { // google-vertex-anthropic shape: Anthropic wire, vendor npm, no api — - // without a prompt the key would be sent to api.anthropic.com. + // self-contained via Google Vertex ADC auth. expect( resolveCatalogImport({ id: 'google-vertex-anthropic', npm: '@ai-sdk/google-vertex/anthropic' }), - ).toEqual({ kind: 'needs-base-url', wire: 'anthropic', guessed: false }); + ).toEqual({ kind: 'ok', wire: 'google-vertex-anthropic', guessed: false }); // kimi-for-coding declares a concrete api — no prompt needed. expect( resolveCatalogImport({ @@ -186,9 +186,9 @@ describe('resolveCatalogImport — endpoint resolution', () => { ), ).toEqual({ kind: 'ok', - wire: 'anthropic', + wire: 'google-vertex-anthropic', guessed: false, - baseUrl: 'https://gateway.example.test', + baseUrl: 'https://gateway.example.test/v1', }); }); diff --git a/packages/kosong/test/vertex-service-account.test.ts b/packages/kosong/test/vertex-service-account.test.ts new file mode 100644 index 0000000000..7dda1bb39a --- /dev/null +++ b/packages/kosong/test/vertex-service-account.test.ts @@ -0,0 +1,112 @@ +import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it, afterAll, beforeAll } from 'vitest'; +import { + createProvider, + expandHomePath, + tryReadProjectIdFromServiceAccount, + GoogleGenAIChatProvider, + AnthropicChatProvider, +} from '../src'; + +describe('Vertex AI Service Account Support', () => { + const tmpDir = join(homedir(), '.test-secrets-tmp'); + const saFileRel = '~/.test-secrets-tmp/test-sa.json'; + const saFileAbs = join(tmpDir, 'test-sa.json'); + + beforeAll(() => { + mkdirSync(tmpDir, { recursive: true }); + writeFileSync( + saFileAbs, + JSON.stringify({ + type: 'service_account', + project_id: 'sa-project-123', + private_key_id: 'key-id-456', + private_key: '-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSlAgEAAoIBAQC...\n-----END PRIVATE KEY-----\n', + client_email: 'test-sa@sa-project-123.iam.gserviceaccount.com', + }), + ); + }); + + afterAll(() => { + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore + } + }); + + describe('expandHomePath', () => { + it('expands ~ to home directory', () => { + expect(expandHomePath('~')).toBe(homedir()); + expect(expandHomePath('~/my-folder/file.json')).toBe(join(homedir(), 'my-folder/file.json')); + expect(expandHomePath('~/.secrets/sa.json')).toBe(join(homedir(), '.secrets/sa.json')); + }); + + it('returns absolute or relative paths unchanged', () => { + expect(expandHomePath('/tmp/sa.json')).toBe('/tmp/sa.json'); + expect(expandHomePath('relative/sa.json')).toBe('relative/sa.json'); + expect(expandHomePath(undefined)).toBeUndefined(); + expect(expandHomePath('')).toBeUndefined(); + }); + }); + + describe('tryReadProjectIdFromServiceAccount', () => { + it('reads project_id from tilde-expanded service account path', () => { + expect(tryReadProjectIdFromServiceAccount(saFileRel)).toBe('sa-project-123'); + expect(tryReadProjectIdFromServiceAccount(saFileAbs)).toBe('sa-project-123'); + }); + + it('returns undefined for non-existent file or invalid JSON', () => { + expect(tryReadProjectIdFromServiceAccount('/non-existent/file.json')).toBeUndefined(); + expect(tryReadProjectIdFromServiceAccount(undefined)).toBeUndefined(); + }); + }); + + describe('google-vertex provider', () => { + it('initializes GoogleGenAIChatProvider with serviceAccountFile and reads project_id', () => { + const provider = createProvider({ + type: 'google-vertex', + model: 'gemini-2.5-pro', + serviceAccountFile: saFileRel, + location: 'us-central1', + }); + + expect(provider).toBeInstanceOf(GoogleGenAIChatProvider); + expect(provider.modelName).toBe('gemini-2.5-pro'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pAny = provider as any; + expect(pAny._serviceAccountFile).toBe(saFileAbs); + expect(pAny._project).toBe('sa-project-123'); + expect(pAny._googleAuthOptions).toEqual({ + keyFilename: saFileAbs, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }); + }); + }); + + describe('google-vertex-anthropic provider', () => { + it('initializes AnthropicChatProvider with vertexai and serviceAccountFile', () => { + const provider = createProvider({ + type: 'google-vertex-anthropic', + model: 'claude-sonnet-4-6', + serviceAccountFile: saFileRel, + location: 'us-east5', + }); + + expect(provider).toBeInstanceOf(AnthropicChatProvider); + expect(provider.modelName).toBe('claude-sonnet-4-6'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pAny = provider as any; + expect(pAny._vertexai).toBe(true); + expect(pAny._serviceAccountFile).toBe(saFileAbs); + expect(pAny._project).toBe('sa-project-123'); + expect(pAny._location).toBe('us-east5'); + expect(pAny._googleAuthOptions).toEqual({ + keyFilename: saFileAbs, + scopes: ['https://www.googleapis.com/auth/cloud-platform'], + }); + }); + }); +}); diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 2890654a0d..3160f2071e 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -66,6 +66,7 @@ "@moonshot-ai/klient": "workspace:^", "@moonshot-ai/kosong": "workspace:^", "@types/yazl": "^2.4.6", + "google-auth-library": "^10.6.2", "jimp": "^1.6.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a29a930cd8..52e56fb07b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -658,6 +658,9 @@ importers: chokidar: specifier: ^4.0.3 version: 4.0.3 + google-auth-library: + specifier: ^10.6.2 + version: 10.6.2 ignore: specifier: ^5.3.2 version: 5.3.2 @@ -851,6 +854,9 @@ importers: '@google/genai': specifier: ^1.49.0 version: 1.49.0(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)) + google-auth-library: + specifier: ^10.6.2 + version: 10.6.2 openai: specifier: ^6.34.0 version: 6.34.0(ws@8.20.0)(zod@4.3.6) @@ -922,6 +928,9 @@ importers: '@types/yazl': specifier: ^2.4.6 version: 2.4.6 + google-auth-library: + specifier: ^10.6.2 + version: 10.6.2 jimp: specifier: ^1.6.1 version: 1.6.1 @@ -13291,7 +13300,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.19.17)(typescript@6.0.2))(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(msw@2.15.0(@types/node@22.19.17)(typescript@6.0.2))(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.4': dependencies: