Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 28 additions & 11 deletions packages/cli/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,30 @@ export namespace Provider {
return isGpt5OrLater(modelID) && !modelID.startsWith("gpt-5-mini")
}

function googleVertexLocation(options: Record<string, any>) {
const raw = options["location"] ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
const location = typeof raw === "string" ? raw.trim().toLowerCase() : ""
if (location.length > 52 || !/^(?:global|us|eu|[a-z]+(?:-[a-z]+)+[0-9]+)$/.test(location)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Regex now rejects zones and legacy values on a backport.

Suggested change
if (location.length > 52 || !/^(?:global|us|eu|[a-z]+(?:-[a-z]+)+[0-9]+)$/.test(location)) {
Either accept zones by stripping a trailing single-letter `-a..z` suffix before validating, or keep the rejection but document the newly rejected forms (zones, empty string, non-strings) in the changelog/release notes for the backport.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:44-45):

Problem: Regex now rejects zones and legacy values on a backport
Detail: The pattern requires a trailing digit run, so zone-style locations like "us-central1-a" (commonly exported in gcloud-oriented shells as GOOGLE_CLOUD_LOCATION) and any legacy value without trailing digits now hard-fail, as do empty strings and non-string config values the old code tolerated. Before this PR such a value loaded fine and merely produced a broken hostname at Vertex request time; combined with the new throw-at-load behavior this is a silent breaking change for existing configs, and this PR targets a backport branch.
Suggested fix: Either accept zones by stripping a trailing single-letter `-a..z` suffix before validating, or keep the rejection but document the newly rejected forms (zones, empty string, non-strings) in the changelog/release notes for the backport.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The pattern requires a trailing digit run, so zone-style locations like "us-central1-a" (commonly exported in gcloud-oriented shells as GOOGLE_CLOUD_LOCATION) and any legacy value without trailing digits now hard-fail, as do empty strings and non-string config values the old code tolerated. Before this PR such a value loaded fine and merely produced a broken hostname at Vertex request time; combined with the new throw-at-load behavior this is a silent breaking change for existing configs, and this PR targets a backport branch.

    const raw = options["location"] ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
    const location = typeof raw === "string" ? raw.trim().toLowerCase() : ""
    if (location.length > 52 || !/^(?:global|us|eu|[a-z]+(?:-[a-z]+)+[0-9]+)$/.test(location)) {
      throw new Error("Invalid Google Vertex location. Use global, us, eu, or a region such as us-central1.")
    }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic length cap 52 for location is unexplained.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:44):

Problem: Magic length cap 52 for location is unexplained
Detail: The `location.length > 52` guard is an undocumented magic number with no named constant or derivation note; the longest real GCP region ids are ~25 chars, so the origin of 52 is opaque to maintainers. The regex on the same line already encodes the shape rules.
Suggested fix: Extract a named constant with a short derivation comment, e.g. `const MAX_VERTEX_LOCATION_LENGTH = 52 // defensive cap; longest GCP region ids are ~25 chars`, or encode segment length bounds in the regex instead of a separate numeric cap.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The location.length > 52 guard is an undocumented magic number with no named constant or derivation note; the longest real GCP region ids are ~25 chars, so the origin of 52 is opaque to maintainers. The regex on the same line already encodes the shape rules.

    const location = typeof raw === "string" ? raw.trim().toLowerCase() : ""
    if (location.length > 52 || !/^(?:global|us|eu|[a-z]+(?:-[a-z]+)+[0-9]+)$/.test(location)) {
      throw new Error("Invalid Google Vertex location. Use global, us, eu, or a region such as us-central1.")
    }

throw new Error("Invalid Google Vertex location. Use global, us, eu, or a region such as us-central1.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Bare new Error deviates from NamedError convention.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:45):

Problem: Bare new Error deviates from NamedError convention
Detail: provider.ts reports failures through structured typed errors built from NamedError.create in packages/util/src/error.ts (e.g. `throw new InitError({ providerID }, { cause: e })` in getSDK, ModelNotFoundError in getModel). The new `throw new Error("Invalid Google Vertex location...")` is a bare string Error, bypassing the structured error pipeline the rest of this namespace uses for user-facing reporting.
Suggested fix: Define the error via NamedError.create (e.g. ProviderOptionError with providerID/option/value fields) and throw it with the offending value and providerID, keeping the human-readable hint in the message.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

provider.ts reports failures through structured typed errors built from NamedError.create in packages/util/src/error.ts (e.g. throw new InitError({ providerID }, { cause: e }) in getSDK, ModelNotFoundError in getModel). The new throw new Error("Invalid Google Vertex location...") is a bare string Error, bypassing the structured error pipeline the rest of this namespace uses for user-facing reporting.

    const location = typeof raw === "string" ? raw.trim().toLowerCase() : ""
    if (location.length > 52 || !/^(?:global|us|eu|[a-z]+(?:-[a-z]+)+[0-9]+)$/.test(location)) {
      throw new Error("Invalid Google Vertex location. Use global, us, eu, or a region such as us-central1.")
    }

}
return location
}

function googleVertexEndpoint(location: string) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Unvalidated Vertex location allows endpoint host injection.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:41-45):

Problem: Unvalidated Vertex location allows endpoint host injection
Detail: googleVertexEndpoint() interpolates `location` (from aictrl.json provider options or GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION env) directly into the endpoint hostname with no validation. A value containing `/` or other URL metacharacters (e.g. `attacker.com/`) breaks out of the hostname suffix, producing `https://attacker.com/-aiplatform.googleapis.com/v1/...`, so every request — including the `Authorization: Bearer` header carrying the ADC cloud-platform OAuth token attached in the fetch wrapper below — is sent to an attacker-controlled host. Because aictrl.json is loaded from the working directory (frequently a cloned/shared repo), a malicious repo config can exfiltrate the victim's GCP access token. The pattern is pre-existing, but this PR is the endpoint-hardening change and the new helper is the natural validation point. Repro: place `{"provider":{"google-vertex":{"options":{"location":"attacker.com/"}}}}` in a shared repo's aictrl.json; victim runs any command loading the google-vertex provider with ADC present; requests (with Bearer token) go to attacker.com.
Suggested fix: Validate location in googleVertexEndpoint() before interpolating: accept only `global`, `eu`, `us`, or strings matching /^[a-z0-9-]+$/ (optionally against a known GCP region list); throw a config error otherwise. This also transitively sanitizes the GOOGLE_VERTEX_LOCATION path-segment substitution in the API URL template.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

googleVertexEndpoint() interpolates location (from aictrl.json provider options or GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION env) directly into the endpoint hostname with no validation. A value containing / or other URL metacharacters (e.g. attacker.com/) breaks out of the hostname suffix, producing https://attacker.com/-aiplatform.googleapis.com/v1/..., so every request — including the Authorization: Bearer header carrying the ADC cloud-platform OAuth token attached in the fetch wrapper below — is sent to an attacker-controlled host. Because aictrl.json is loaded from the working directory (frequently a cloned/shared repo), a malicious repo config can exfiltrate the victim's GCP access token. The pattern is pre-existing, but this PR is the endpoint-hardening change and the new helper is the natural validation point. Repro: place {"provider":{"google-vertex":{"options":{"location":"attacker.com/"}}}} in a shared repo's aictrl.json; victim runs any command loading the google-vertex provider with ADC present; requests (with Bearer token) go to attacker.com.

  function googleVertexEndpoint(location: string) {
    if (location === "global") return "aiplatform.googleapis.com"
    if (location === "eu" || location === "us") return `aiplatform.${location}.rep.googleapis.com`
    return `${location}-aiplatform.googleapis.com`
  }

  function googleVertexVars(options: Record<string, any>) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Vertex location not normalized; EU/US miss rep endpoint.

Suggested change
function googleVertexEndpoint(location: string) {
Normalize once: `const loc = location.trim().toLowerCase()` at the top of googleVertexEndpoint (and reuse the normalized value for GOOGLE_VERTEX_LOCATION) so EU/us/"global " all resolve to the intended endpoints.
🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:41-44):

Problem: Vertex location not normalized; EU/US miss rep endpoint
Detail: googleVertexEndpoint() exact-matches the lowercase strings "eu"/"us" (and "global") without trimming or lowercasing the resolved location. A user who sets GOOGLE_CLOUD_LOCATION=EU or VERTEX_LOCATION=US (or a value with trailing whitespace) falls through to the regional template and gets an invalid host like EU-aiplatform.googleapis.com, defeating the new multi-region endpoint support with a DNS failure instead of using aiplatform.eu.rep.googleapis.com. Repro: Given GOOGLE_CLOUD_LOCATION=EU and a google-vertex provider using default location resolution, When any command loads the provider, Then GOOGLE_VERTEX_ENDPOINT resolves to "EU-aiplatform.googleapis.com" and every model request fails DNS resolution.
Suggested fix: Normalize once: `const loc = location.trim().toLowerCase()` at the top of googleVertexEndpoint (and reuse the normalized value for GOOGLE_VERTEX_LOCATION) so EU/us/"global " all resolve to the intended endpoints.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

googleVertexEndpoint() exact-matches the lowercase strings "eu"/"us" (and "global") without trimming or lowercasing the resolved location. A user who sets GOOGLE_CLOUD_LOCATION=EU or VERTEX_LOCATION=US (or a value with trailing whitespace) falls through to the regional template and gets an invalid host like EU-aiplatform.googleapis.com, defeating the new multi-region endpoint support with a DNS failure instead of using aiplatform.eu.rep.googleapis.com. Repro: Given GOOGLE_CLOUD_LOCATION=EU and a google-vertex provider using default location resolution, When any command loads the provider, Then GOOGLE_VERTEX_ENDPOINT resolves to "EU-aiplatform.googleapis.com" and every model request fails DNS resolution.

  function googleVertexEndpoint(location: string) {
    if (location === "global") return "aiplatform.googleapis.com"
    if (location === "eu" || location === "us") return `aiplatform.${location}.rep.googleapis.com`
    return `${location}-aiplatform.googleapis.com`
  }

  function googleVertexVars(options: Record<string, any>) {

if (location === "global") return "aiplatform.googleapis.com"
if (location === "eu" || location === "us") return `aiplatform.${location}.rep.googleapis.com`
return `${location}-aiplatform.googleapis.com`
}

function googleVertexVars(options: Record<string, any>) {
const project =
options["project"] ?? Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
const location =
options["location"] ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
const location = googleVertexLocation(options)

return {
GOOGLE_VERTEX_PROJECT: project,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 GOOGLE_VERTEX_PROJECT interpolated into URL unvalidated.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:62-64):

Problem: GOOGLE_VERTEX_PROJECT interpolated into URL unvalidated
Detail: googleVertexVars() strictly validates `location` but exports `project` verbatim from the same untrusted sources (aictrl.json options or GOOGLE_CLOUD_PROJECT/GCP_PROJECT/GCLOUD_PROJECT env) as GOOGLE_VERTEX_PROJECT, which is string-interpolated into the templated API URL path (https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/...). A project value containing '/', '?', '#', or '..' rewrites the path or query on the validated googleapis.com host — the same trust boundary and interpolation pattern that motivated the location hardening, left unchecked. Impact is bounded (the host is already validated) but it enables path/query confusion.
Suggested fix: Validate project alongside location, e.g. `if (!/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/.test(project) && !/^[0-9]{4,30}$/.test(project)) throw ...` (project ID or project number) inside googleVertexVars, mirroring googleVertexLocation.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

googleVertexVars() strictly validates location but exports project verbatim from the same untrusted sources (aictrl.json options or GOOGLE_CLOUD_PROJECT/GCP_PROJECT/GCLOUD_PROJECT env) as GOOGLE_VERTEX_PROJECT, which is string-interpolated into the templated API URL path (https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/...). A project value containing '/', '?', '#', or '..' rewrites the path or query on the validated googleapis.com host — the same trust boundary and interpolation pattern that motivated the location hardening, left unchecked. Impact is bounded (the host is already validated) but it enables path/query confusion.

  function googleVertexVars(options: Record<string, any>) {
    const project =
      options["project"] ?? Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
    const location = googleVertexLocation(options)

    return {
      GOOGLE_VERTEX_PROJECT: project,

GOOGLE_VERTEX_LOCATION: location,
GOOGLE_VERTEX_ENDPOINT: endpoint,
GOOGLE_VERTEX_ENDPOINT: googleVertexEndpoint(location),
}
}

Expand Down Expand Up @@ -365,21 +378,21 @@ export namespace Provider {
Env.get("GCP_PROJECT") ??
Env.get("GCLOUD_PROJECT")

const location =
provider.options?.location ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"

const autoload = Boolean(project)
if (!autoload) return { autoload: false }
const location = googleVertexLocation(provider.options ?? {})
const { GoogleAuth } = await import("google-auth-library")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

google-auth-library import now eager at provider load.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:384-386):

Problem: google-auth-library import now eager at provider load
Detail: `await import("google-auth-library")` moved from inside the fetch callback (loaded on first request, only for actual Vertex usage) to provider-load time: it now runs during state initialization whenever a project env var is set (GOOGLE_CLOUD_PROJECT/GCP_PROJECT/GCLOUD_PROJECT are common in GCP shells and CI), adding the module-load cost to every CLI startup for users who may never use Vertex. If the dynamic import ever rejects, the throw now happens inside the shared CUSTOM_LOADERS loop and breaks all provider resolution instead of a single provider's request.
Suggested fix: Keep the module lazy while sharing the instance: memoize inside the closure, e.g. `const getAuth = async () => new GoogleAuth({ scopes: [...] })` with the import inside a lazily-resolved promise, invoked from the fetch wrapper on first use.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

await import("google-auth-library") moved from inside the fetch callback (loaded on first request, only for actual Vertex usage) to provider-load time: it now runs during state initialization whenever a project env var is set (GOOGLE_CLOUD_PROJECT/GCP_PROJECT/GCLOUD_PROJECT are common in GCP shells and CI), adding the module-load cost to every CLI startup for users who may never use Vertex. If the dynamic import ever rejects, the throw now happens inside the shared CUSTOM_LOADERS loop and breaks all provider resolution instead of a single provider's request.

      const autoload = Boolean(project)
      if (!autoload) return { autoload: false }
      const location = googleVertexLocation(provider.options ?? {})
      const { GoogleAuth } = await import("google-auth-library")
      // GoogleAuth shares ADC resolution and token-refresh state for this provider.
      // Credential discovery remains lazy until the first custom fetch.
      const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] })

// GoogleAuth shares ADC resolution and token-refresh state for this provider.
// Credential discovery remains lazy until the first custom fetch.
const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] })
return {
autoload: true,
options: {
project,
location,
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const { GoogleAuth } = await import("google-auth-library")
const auth = new GoogleAuth()
const client = await auth.getApplicationDefault()
const token = await client.credential.getAccessToken()
const client = await auth.getClient()
const token = await client.getAccessToken()

const headers = new Headers(init?.headers)
headers.set("Authorization", `Bearer ${token.token}`)
Expand Down Expand Up @@ -1093,6 +1106,10 @@ export namespace Provider {
continue
}

// Config options are merged after custom loaders; normalize the final value
// for native SDKs as well as templated OpenAI-compatible endpoints.
if (providerID === "google-vertex") provider.options.location = googleVertexLocation(provider.options)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Invalid Vertex location crashes all provider loading.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #118, packages/cli/src/provider/provider.ts:1111):

Problem: Invalid Vertex location crashes all provider loading
Detail: googleVertexLocation() throws on invalid input, and it is invoked unguarded in two places that run for every provider load: the google-vertex custom loader (provider.ts:382, called via `const result = await fn(data)` at provider.ts:1085 with no try/catch) and the all-providers normalization loop (provider.ts:1111). Both sit inside `Instance.state(...)`, so one bad GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION value or aictrl.json `options.location` (a zone like "us-central1-a", a typo, an empty string, or a non-string) makes state() reject and Provider.list/getProvider/getModel/defaultModel fail for ALL providers, including unrelated ones. Before this PR the same values only produced a broken hostname for google-vertex models at request time. The neighbouring code in the same loops degrades gracefully (log.error + continue at provider.ts:1082-1083, delete + continue at provider.ts:1105-1106), so the fail-hard crash is also inconsistent with the file's own convention. Repro: Given GOOGLE_CLOUD_LOCATION=us-central1-a (a zone, commonly exported in gcloud shells) and any config that mentions google-vertex, When the user runs any command that resolves providers, Then state initialization throws "Invalid Google Vertex location" and every provider/model operation fails, including unrelated providers.
Suggested fix: Contain the failure to the google-vertex entry, matching the loop's existing degradation convention: wrap the normalization in try/catch and on failure log.error the reason and delete/skip the google-vertex provider (`try { provider.options.location = googleVertexLocation(provider.options) } catch (e) { log.error(String(e)); delete providers[providerID]; continue }`), and/or catch the loader throw at the `await fn(data)` site. Alternatively defer validation to first use (fetch/getSDK) instead of provider enumeration so a misconfigured Vertex cannot take down unrelated providers.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

googleVertexLocation() throws on invalid input, and it is invoked unguarded in two places that run for every provider load: the google-vertex custom loader (provider.ts:382, called via const result = await fn(data) at provider.ts:1085 with no try/catch) and the all-providers normalization loop (provider.ts:1111). Both sit inside Instance.state(...), so one bad GOOGLE_CLOUD_LOCATION/VERTEX_LOCATION value or aictrl.json options.location (a zone like "us-central1-a", a typo, an empty string, or a non-string) makes state() reject and Provider.list/getProvider/getModel/defaultModel fail for ALL providers, including unrelated ones. Before this PR the same values only produced a broken hostname for google-vertex models at request time. The neighbouring code in the same loops degrades gracefully (log.error + continue at provider.ts:1082-1083, delete + continue at provider.ts:1105-1106), so the fail-hard crash is also inconsistent with the file's own convention. Repro: Given GOOGLE_CLOUD_LOCATION=us-central1-a (a zone, commonly exported in gcloud shells) and any config that mentions google-vertex, When the user runs any command that resolves providers, Then state initialization throws "Invalid Google Vertex location" and every provider/model operation fails, including unrelated providers.

      if (!isProviderAllowed(providerID)) {
        delete providers[providerID]
        continue
      }

      // Config options are merged after custom loaders; normalize the final value
      // for native SDKs as well as templated OpenAI-compatible endpoints.
      if (providerID === "google-vertex") provider.options.location = googleVertexLocation(provider.options)

      const configProvider = config.provider?.[providerID]


const configProvider = config.provider?.[providerID]

for (const [modelID, model] of Object.entries(provider.models)) {
Expand Down
214 changes: 214 additions & 0 deletions packages/cli/test/provider/google-vertex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { expect, spyOn, test } from "bun:test"
import { GoogleAuth, OAuth2Client } from "google-auth-library"
import path from "path"

import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Env } from "../../src/env"

test.each([
["global", "aiplatform.googleapis.com"],
["us", "aiplatform.us.rep.googleapis.com"],
["eu", "aiplatform.eu.rep.googleapis.com"],
["europe-west1", "europe-west1-aiplatform.googleapis.com"],
[" EU ", "aiplatform.eu.rep.googleapis.com"],
["US", "aiplatform.us.rep.googleapis.com"],
[" Global\t", "aiplatform.googleapis.com"],
[" EUROPE-WEST1 ", "europe-west1-aiplatform.googleapis.com"],
])("Google Vertex resolves the %s endpoint", async (location, endpoint) => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "aictrl.json"),
JSON.stringify({
$schema: "https://aictrl.ai/config.json",
provider: {
"google-vertex": {
options: {
project: "test-project",
location,
},
models: {
"test-model": {
name: "Test Model",
tool_call: true,
provider: {
npm: "@ai-sdk/openai-compatible",
api: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
},
},
},
},
},
}),
)
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const { Provider } = await import("../../src/provider/provider")
const model = await Provider.getModel("google-vertex", "test-model")
expect((await Provider.getProvider("google-vertex")).options.location).toBe(location.trim().toLowerCase())
const language = (await Provider.getLanguage(model)) as unknown as {
config: { url(input: { path: string }): string }
}

expect(language.config.url({ path: "/chat/completions" })).toBe(
`https://${endpoint}/v1/projects/test-project/locations/${location.trim().toLowerCase()}/chat/completions`,
)
},
})
})

test.each([
"attacker.com/",
"us@attacker.com",
"us\\attacker.com",
"us?x=1",
"us#fragment",
"us%2fhost",
"",
123,
"a".repeat(60) + "-west1",
])("Google Vertex rejects unsafe location %s before resolving a client or constructing an SDK", async (location) => {
await using tmp = await tmpdir({
config: {
provider: { "google-vertex": { options: { project: "test-project", location } } },
},
})
const client = spyOn(GoogleAuth.prototype, "getClient")
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { Provider } = await import("../../src/provider/provider")
await expect(Provider.getProvider("google-vertex")).rejects.toThrow("Invalid Google Vertex location")
expect(client).not.toHaveBeenCalled()
},
})
} finally {
client.mockRestore()
}
})

test.each([
["GOOGLE_CLOUD_LOCATION", " EU ", "eu"],
["VERTEX_LOCATION", " US ", "us"],
["GOOGLE_CLOUD_LOCATION", "attacker.com/", undefined],
["VERTEX_LOCATION", "us@attacker.com", undefined],
] as const)("Google Vertex validates location from %s", async (key, value, expected) => {
await using tmp = await tmpdir({
config: { provider: { "google-vertex": { options: { project: "test-project" } } } },
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.remove("GOOGLE_CLOUD_LOCATION")
Env.remove("VERTEX_LOCATION")
Env.set(key, value)
},
fn: async () => {
const { Provider } = await import("../../src/provider/provider")
if (expected === undefined) {
await expect(Provider.getProvider("google-vertex")).rejects.toThrow("Invalid Google Vertex location")
return
}
expect((await Provider.getProvider("google-vertex")).options.location).toBe(expected)
},
})
})

test("Google Vertex reuses its auth instance and cached client across requests", async () => {
await using tmp = await tmpdir({
config: {
provider: { "google-vertex": { options: { project: "test-project", location: "us-central1" } } },
},
})
const credential = new OAuth2Client()
credential.setCredentials({ access_token: "synthetic-cached-token", expiry_date: Date.now() + 3600000 })
const instances = new Set<GoogleAuth>()
const original = GoogleAuth.prototype.getClient
const client = spyOn(GoogleAuth.prototype, "getClient").mockImplementation(function (this: GoogleAuth) {
instances.add(this)
this.cachedCredential ??= credential
return original.call(this)
})
const tokens = spyOn(credential, "getAccessToken")
const server = Bun.serve({
port: 0,
fetch(request) {
expect(request.headers.get("authorization")).toBe("Bearer synthetic-cached-token")
return new Response("ok")
},
})
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const { Provider } = await import("../../src/provider/provider")
const provider = await Provider.getProvider("google-vertex")
expect(client).not.toHaveBeenCalled()
const requests = await Promise.all([provider.options.fetch(server.url), provider.options.fetch(server.url)])
expect(await Promise.all(requests.map((response: Response) => response.text()))).toEqual(["ok", "ok"])
expect(instances.size).toBe(1)
expect(client).toHaveBeenCalledTimes(2)
expect(tokens).toHaveBeenCalledTimes(2)
expect(await Promise.all(tokens.mock.results.map((result) => result.value))).toEqual([
{ token: "synthetic-cached-token" },
{ token: "synthetic-cached-token" },
])
},
})
} finally {
server.stop(true)
tokens.mockRestore()
client.mockRestore()
}
})

test("Google Vertex requests the cloud-platform OAuth scope", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "aictrl.json"),
JSON.stringify({
$schema: "https://aictrl.ai/config.json",
provider: {
"google-vertex": {
options: {
project: "test-project",
location: "us-central1",
},
},
},
}),
)
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const { Provider } = await import("../../src/provider/provider")
const provider = await Provider.getProvider("google-vertex")

const client = spyOn(GoogleAuth.prototype, "getClient").mockImplementation(async function (this: GoogleAuth) {
expect(Reflect.get(this, "scopes")).toEqual(["https://www.googleapis.com/auth/cloud-platform"])
throw new Error("stop after resolving auth client")
})
const defaults = spyOn(GoogleAuth.prototype, "getApplicationDefault").mockImplementation(() => {
throw new Error("unexpected application default lookup")
})
try {
await expect(provider.options.fetch("https://example.test")).rejects.toThrow("stop after resolving auth client")
expect(client).toHaveBeenCalledTimes(1)
expect(defaults).not.toHaveBeenCalled()
} finally {
client.mockRestore()
defaults.mockRestore()
}
},
})
})