diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 709ae712..f0baee2d 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -5,9 +5,11 @@ ### Added - Added GPT-5.5 to OpenAI Codex model generation. +- Added `findEnvKeys()` so callers can identify configured provider API-key environment variables without exposing credential values while preserving `getEnvApiKey()` as the credential-value API. ### Fixed +- Fixed `google-vertex` to forward custom `model.baseUrl` values to `@google/genai`, enabling Vertex proxy and gateway endpoints ([#3619](https://github.com/badlogic/pi-mono/issues/3619)) - Fixed OpenAI-compatible completion usage parsing to stop double-counting reasoning tokens already included in `completion_tokens` ([#3581](https://github.com/badlogic/pi-mono/issues/3581)) - Fixed long cache retention compatibility by adding `compat.supportsLongCacheRetention`, allowing Anthropic Messages and OpenAI-compatible proxies to explicitly disable long-retention fields while enabling long retention by default when requested ([#3543](https://github.com/badlogic/pi-mono/issues/3543)) - Fixed `openai-responses` compatibility by adding `compat.sendSessionIdHeader: false`, allowing strict OpenAI-compatible proxies to omit the underscore-containing `session_id` header while still sending other session-affinity headers ([#3579](https://github.com/badlogic/pi-mono/issues/3579)) @@ -16,6 +18,7 @@ - Fixed `openai-completions` streamed tool-call assembly to coalesce deltas by stable tool index when OpenAI-compatible gateways mutate tool call IDs mid-stream, preventing malformed Kimi K2.6/OpenCode tool streams from splitting one call into multiple bogus tool calls ([#3576](https://github.com/badlogic/pi-mono/issues/3576)) - Fixed `packages/ai` E2E coverage to use currently supported OpenAI Responses and OpenAI Codex models, and updated the Bedrock adaptive-thinking payload expectation to match the current `display: "summarized"` shape. - Fixed built-in `kimi-coding` model generation to attach `User-Agent: KimiCLI/1.5` to all generated Kimi models, overriding the Anthropic SDK default UA so direct Kimi Coding requests use the provider's expected client identity ([#3586](https://github.com/badlogic/pi-mono/issues/3586)) +- Fixed GPT-5.5 Codex capability handling to clamp unsupported minimal reasoning to `low` and apply the model's 2.5x priority service-tier pricing multiplier ([#3618](https://github.com/badlogic/pi-mono/pull/3618) by [@markusylisiurunen](https://github.com/markusylisiurunen)) ## [0.69.0] - 2026-04-22 diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index 4e52be08..739fff27 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -2,6 +2,8 @@ import { type GenerateContentConfig, type GenerateContentParameters, GoogleGenAI, + type HttpOptions, + ResourceScope, type ThinkingConfig, ThinkingLevel, } from "@google/genai"; @@ -331,20 +333,12 @@ function createClient( location: string, optionsHeaders?: Record, ): GoogleGenAI { - const httpOptions: { headers?: Record } = {}; - - if (model.headers || optionsHeaders) { - httpOptions.headers = { ...model.headers, ...optionsHeaders }; - } - - const hasHttpOptions = Object.values(httpOptions).some(Boolean); - return new GoogleGenAI({ vertexai: true, project, location, apiVersion: API_VERSION, - httpOptions: hasHttpOptions ? httpOptions : undefined, + httpOptions: buildHttpOptions(model, optionsHeaders), }); } @@ -353,20 +347,50 @@ function createClientWithApiKey( apiKey: string, optionsHeaders?: Record, ): GoogleGenAI { - const httpOptions: { headers?: Record } = {}; + return new GoogleGenAI({ + vertexai: true, + apiKey, + apiVersion: API_VERSION, + httpOptions: buildHttpOptions(model, optionsHeaders), + }); +} + +function buildHttpOptions( + model: Model<"google-vertex">, + optionsHeaders?: Record, +): HttpOptions | undefined { + const httpOptions: HttpOptions = {}; + const baseUrl = resolveCustomBaseUrl(model.baseUrl); + if (baseUrl) { + httpOptions.baseUrl = baseUrl; + httpOptions.baseUrlResourceScope = ResourceScope.COLLECTION; + if (baseUrlIncludesApiVersion(baseUrl)) { + httpOptions.apiVersion = ""; + } + } if (model.headers || optionsHeaders) { httpOptions.headers = { ...model.headers, ...optionsHeaders }; } - const hasHttpOptions = Object.values(httpOptions).some(Boolean); + return Object.keys(httpOptions).length > 0 ? httpOptions : undefined; +} - return new GoogleGenAI({ - vertexai: true, - apiKey, - apiVersion: API_VERSION, - httpOptions: hasHttpOptions ? httpOptions : undefined, - }); +function resolveCustomBaseUrl(baseUrl: string): string | undefined { + const trimmed = baseUrl.trim(); + if (!trimmed || trimmed.includes("{location}")) { + return undefined; + } + return trimmed; +} + +function baseUrlIncludesApiVersion(baseUrl: string): boolean { + try { + const url = new URL(baseUrl); + return url.pathname.split("/").some((part) => /^v\d+(?:beta\d*)?$/.test(part)); + } catch { + return /(?:^|\/)v\d+(?:beta\d*)?(?:\/|$)/.test(baseUrl); + } } function resolveApiKey(options?: GoogleVertexOptions): string | undefined { diff --git a/packages/ai/test/google-vertex-api-key-resolution.test.ts b/packages/ai/test/google-vertex-api-key-resolution.test.ts index 1b3a0655..d05702c3 100644 --- a/packages/ai/test/google-vertex-api-key-resolution.test.ts +++ b/packages/ai/test/google-vertex-api-key-resolution.test.ts @@ -32,6 +32,9 @@ vi.mock("@google/genai", () => { return { GoogleGenAI, + ResourceScope: { + COLLECTION: "COLLECTION", + }, ThinkingLevel: { THINKING_LEVEL_UNSPECIFIED: "THINKING_LEVEL_UNSPECIFIED", MINIMAL: "MINIMAL", @@ -44,7 +47,7 @@ vi.mock("@google/genai", () => { import { getModel } from "../src/models.js"; import { streamGoogleVertex } from "../src/providers/google-vertex.js"; -import type { Context } from "../src/types.js"; +import type { Context, Model } from "../src/types.js"; const model = getModel("google-vertex", "gemini-3-flash-preview"); const context: Context = { @@ -141,4 +144,80 @@ describe("google-vertex api key resolution", () => { expect(googleGenAiMock.constructorCalls[0]).not.toHaveProperty("project"); expect(googleGenAiMock.constructorCalls[0]).not.toHaveProperty("location"); }); + + it("does not forward generated Vertex base URL placeholders", async () => { + const stream = streamGoogleVertex(model, context, { + project: "test-project", + location: "us-central1", + }); + + await stream.result(); + + expect(googleGenAiMock.constructorCalls).toHaveLength(1); + expect(googleGenAiMock.constructorCalls[0]?.httpOptions).toBeUndefined(); + }); + + it("forwards custom baseUrl to the ADC client", async () => { + const customModel: Model<"google-vertex"> = { ...model, baseUrl: "https://proxy.example.com" }; + const stream = streamGoogleVertex(customModel, context, { + project: "test-project", + location: "us-central1", + }); + + await stream.result(); + + expect(googleGenAiMock.constructorCalls).toHaveLength(1); + expect(googleGenAiMock.constructorCalls[0]).toMatchObject({ + vertexai: true, + project: "test-project", + location: "us-central1", + apiVersion: "v1", + httpOptions: { + baseUrl: "https://proxy.example.com", + baseUrlResourceScope: "COLLECTION", + }, + }); + }); + + it("forwards custom baseUrl to the API key client", async () => { + const customModel: Model<"google-vertex"> = { ...model, baseUrl: "https://proxy.example.com" }; + const stream = streamGoogleVertex(customModel, context, { + apiKey: "AIzaSyExampleRealisticLookingApiKey123456", + }); + + await stream.result(); + + expect(googleGenAiMock.constructorCalls).toHaveLength(1); + expect(googleGenAiMock.constructorCalls[0]).toMatchObject({ + vertexai: true, + apiKey: "AIzaSyExampleRealisticLookingApiKey123456", + apiVersion: "v1", + httpOptions: { + baseUrl: "https://proxy.example.com", + baseUrlResourceScope: "COLLECTION", + }, + }); + }); + + it("does not append apiVersion when custom baseUrl already includes one", async () => { + const customModel: Model<"google-vertex"> = { + ...model, + baseUrl: "https://proxy.example.com/v1/projects/test-project/locations/global", + }; + const stream = streamGoogleVertex(customModel, context, { + project: "test-project", + location: "us-central1", + }); + + await stream.result(); + + expect(googleGenAiMock.constructorCalls).toHaveLength(1); + expect(googleGenAiMock.constructorCalls[0]).toMatchObject({ + httpOptions: { + baseUrl: "https://proxy.example.com/v1/projects/test-project/locations/global", + baseUrlResourceScope: "COLLECTION", + apiVersion: "", + }, + }); + }); });