From 2f148cb2825d4e9d77e156e457b4627bad844785 Mon Sep 17 00:00:00 2001
From: Johnny Huynh <27847622+johnnyhuy@users.noreply.github.com>
Date: Sun, 8 Nov 2026 13:34:23 +0900
Subject: [PATCH] feat(probes): add Bedrock, Vertex and Azure probe kinds
Wire probeKind aws, gcp and azure in runProbe per ADR-0003. Bedrock
signs ListFoundationModels with SigV4 (node:crypto, no new deps) using
the env credential chain and surfaces the AWS error code in detail.
Vertex calls publishers/google/models with project and region from
ProbeContext and errors sensibly without them. Azure lists deployments
with the api-key header and errors without an endpoint. Mocked-HTTP
tests cover the ok and error paths with no live provider keys.
---
package.json | 2 +-
scripts/test-probes-cloud.mjs | 170 ++++++++++++++++++++++++
src/main/probes/aws.ts | 147 ++++++++++++++++++++
src/main/probes/azure.ts | 59 ++++++++
src/main/probes/gcp.ts | 99 ++++++++++++++
src/main/probes/index.ts | 32 +++++
src/main/providers/catalog.generated.ts | 6 +-
src/main/providers/catalog.source.json | 4 +-
8 files changed, 513 insertions(+), 6 deletions(-)
create mode 100644 scripts/test-probes-cloud.mjs
create mode 100644 src/main/probes/aws.ts
create mode 100644 src/main/probes/azure.ts
create mode 100644 src/main/probes/gcp.ts
diff --git a/package.json b/package.json
index 097e137..513a989 100644
--- a/package.json
+++ b/package.json
@@ -23,7 +23,7 @@
"prebuild:renderer": "npm run gen:catalog",
"catalog:check": "node scripts/check-catalog-stale.mjs",
"test": "npm run build:main && npm run test:unit",
- "test:unit": "node scripts/test-extract-base-url.mjs && node scripts/test-doctor.mjs && node scripts/test-versions.mjs && node scripts/test-secrets.mjs && node scripts/test-homebrew.mjs && node scripts/test-install-methods.mjs && node scripts/test-codex-auth.mjs",
+ "test:unit": "node scripts/test-extract-base-url.mjs && node scripts/test-doctor.mjs && node scripts/test-versions.mjs && node scripts/test-secrets.mjs && node scripts/test-homebrew.mjs && node scripts/test-install-methods.mjs && node scripts/test-codex-auth.mjs && node scripts/test-probes-cloud.mjs",
"test:extractor": "node scripts/test-extract-base-url.mjs",
"package": "npm run build && electron-builder"
},
diff --git a/scripts/test-probes-cloud.mjs b/scripts/test-probes-cloud.mjs
new file mode 100644
index 0000000..550e956
--- /dev/null
+++ b/scripts/test-probes-cloud.mjs
@@ -0,0 +1,170 @@
+#!/usr/bin/env node
+/**
+ * Unit tests for src/main/probes/{aws,gcp,azure}.ts (via dist build).
+ * All HTTP is mocked with a local fixture server. No live provider keys.
+ */
+import { createRequire } from 'node:module'
+import { createServer } from 'node:http'
+import { existsSync } from 'node:fs'
+import { join, dirname } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const root = join(dirname(fileURLToPath(import.meta.url)), '..')
+for (const rel of ['dist/main/probes/aws.js', 'dist/main/probes/gcp.js', 'dist/main/probes/azure.js', 'dist/main/probes/index.js']) {
+ if (!existsSync(join(root, rel))) {
+ console.error(`${rel} missing - run npm run build:main first`)
+ process.exit(1)
+ }
+}
+
+const require = createRequire(import.meta.url)
+const { probeBedrock, signBedrockRequest, parseAwsError } = require(join(root, 'dist/main/probes/aws.js'))
+const { probeVertex, vertexModelsUrl } = require(join(root, 'dist/main/probes/gcp.js'))
+const { probeAzure } = require(join(root, 'dist/main/probes/azure.js'))
+const { runProbe } = require(join(root, 'dist/main/probes/index.js'))
+
+let failed = 0
+function assert(cond, msg) {
+ if (cond) console.log(` ✓ ${msg}`)
+ else {
+ failed++
+ console.error(` ✗ ${msg}`)
+ }
+}
+
+// Deterministic fixture server: handler swapped per case, requests recorded.
+let handler = (_req, res) => { res.writeHead(200); res.end('{}') }
+const requests = []
+const server = createServer((req, res) => {
+ requests.push({ method: req.method, url: req.url, headers: req.headers })
+ handler(req, res)
+})
+await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
+const endpoint = `http://127.0.0.1:${server.address().port}`
+const lastRequest = () => requests[requests.length - 1]
+
+// Keep the AWS env chain empty unless a case sets it explicitly.
+const savedEnv = { ...process.env }
+for (const key of ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_SESSION_TOKEN', 'AWS_REGION', 'AWS_DEFAULT_REGION']) {
+ delete process.env[key]
+}
+
+const fakeCreds = { accessKeyId: 'AKIDEXAMPLE', secretAccessKey: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY' }
+
+// AWS: unconfigured credential chain.
+{
+ const r = await probeBedrock({ endpoint })
+ assert(r.valid === false && r.status === 'invalid', 'aws no credentials returns invalid')
+ assert(r.detail.includes('AWS error:'), 'aws no credentials detail carries AWS error')
+}
+
+// AWS: signed request validates against a mocked 200.
+{
+ handler = (_req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"modelSummaries":[]}') }
+ const before = requests.length
+ const r = await probeBedrock({ credentials: fakeCreds, region: 'us-east-1', endpoint })
+ assert(r.valid === true && r.status === 'ok', 'aws mocked 200 returns ok')
+ assert(requests.length === before + 1, 'aws probe hit the fixture server once')
+ const req = lastRequest()
+ assert(req.url === '/foundation-models', 'aws request path is /foundation-models')
+ assert(req.headers['x-amz-date'], 'aws request carries x-amz-date')
+ const auth = req.headers.authorization ?? ''
+ assert(auth.startsWith('AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/'), 'aws Authorization uses SigV4 credential scope')
+ assert(auth.includes('/us-east-1/bedrock/aws4_request'), 'aws credential scope names region and bedrock service')
+ assert(/Signature=[0-9a-f]{64}/.test(auth), 'aws Authorization carries a hex signature')
+}
+
+// AWS: mocked 403 XML error surfaces the AWS error code.
+{
+ handler = (_req, res) => {
+ res.writeHead(403, { 'content-type': 'application/xml' })
+ res.end('AccessDeniedExceptionnot entitled')
+ }
+ const r = await probeBedrock({ credentials: fakeCreds, region: 'us-east-1', endpoint })
+ assert(r.valid === false && r.status === 'invalid', 'aws mocked 403 returns invalid')
+ assert(r.detail.includes('AWS error: AccessDeniedException'), 'aws detail surfaces the AWS error code')
+ assert(r.detail.includes('not entitled'), 'aws detail surfaces the AWS error message')
+}
+
+// AWS: signing is deterministic for a fixed clock and secret-sensitive.
+{
+ const now = new Date('2026-01-02T03:04:05Z')
+ const a = signBedrockRequest({ endpoint: 'https://bedrock.us-east-1.amazonaws.com', region: 'us-east-1', credentials: fakeCreds, now })
+ const b = signBedrockRequest({ endpoint: 'https://bedrock.us-east-1.amazonaws.com', region: 'us-east-1', credentials: fakeCreds, now })
+ assert(a.headers.Authorization === b.headers.Authorization, 'aws SigV4 signature is deterministic for a fixed clock')
+ const c = signBedrockRequest({ endpoint: 'https://bedrock.us-east-1.amazonaws.com', region: 'us-east-1', credentials: { ...fakeCreds, secretAccessKey: 'other-secret' }, now })
+ assert(c.headers.Authorization !== a.headers.Authorization, 'aws SigV4 signature changes with the secret')
+ const parsed = parseAwsError('ValidationExceptionbad')
+ assert(parsed.code === 'ValidationException' && parsed.message === 'bad', 'parseAwsError reads XML code and message')
+}
+
+// GCP: project and region are required before any HTTP call.
+{
+ const before = requests.length
+ const r = await probeVertex({ apiKey: 'ya29.fake-token' })
+ assert(r.valid === false && r.status === 'error', 'gcp missing project and region returns error')
+ assert(r.detail.includes('project and region'), 'gcp missing config detail names project and region')
+ assert(requests.length === before, 'gcp missing config makes no HTTP request')
+ const noToken = await probeVertex({ apiKey: '', project: 'my-project', region: 'us-central1', endpoint })
+ assert(noToken.valid === false && noToken.status === 'invalid', 'gcp missing token returns invalid')
+}
+
+// GCP: mocked 200 validates, mocked 403 surfaces the Google error.
+{
+ handler = (_req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"models":[]}') }
+ const r = await probeVertex({ apiKey: 'ya29.fake-token', project: 'my-project', region: 'us-central1', endpoint })
+ assert(r.valid === true && r.status === 'ok', 'gcp mocked 200 returns ok')
+ const req = lastRequest()
+ assert(req.url === '/v1/projects/my-project/locations/us-central1/publishers/google/models', 'gcp request path carries project and region')
+ assert(req.headers.authorization === 'Bearer ya29.fake-token', 'gcp request sends the Bearer token')
+
+ handler = (_req, res) => {
+ res.writeHead(403, { 'content-type': 'application/json' })
+ res.end('{"error":{"status":"PERMISSION_DENIED","message":"Permission denied"}}')
+ }
+ const denied = await probeVertex({ apiKey: 'ya29.fake-token', project: 'my-project', region: 'us-central1', endpoint })
+ assert(denied.valid === false && denied.status === 'invalid', 'gcp mocked 403 returns invalid')
+ assert(denied.detail.includes('PERMISSION_DENIED'), 'gcp detail surfaces the Google error status')
+ assert(
+ vertexModelsUrl('p', 'global') === 'https://aiplatform.googleapis.com/v1/projects/p/locations/global/publishers/google/models',
+ 'gcp global region uses the global aiplatform host',
+ )
+}
+
+// Azure: endpoint required, mocked 200 validates with the api-key header.
+{
+ const noEndpoint = await probeAzure({ apiKey: 'fake-key' })
+ assert(noEndpoint.valid === false && noEndpoint.status === 'error', 'azure missing endpoint returns error')
+ assert(noEndpoint.detail.includes('endpoint'), 'azure missing endpoint detail names the endpoint')
+
+ handler = (_req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"data":[]}') }
+ const r = await probeAzure({ apiKey: 'fake-key', baseUrl: endpoint })
+ assert(r.valid === true && r.status === 'ok', 'azure mocked 200 returns ok')
+ const req = lastRequest()
+ assert(req.url === '/openai/deployments?api-version=2024-02-01', 'azure request path lists deployments')
+ assert(req.headers['api-key'] === 'fake-key', 'azure request sends the api-key header')
+
+ handler = (_req, res) => { res.writeHead(401); res.end('{}') }
+ const denied = await probeAzure({ apiKey: 'fake-key', baseUrl: endpoint })
+ assert(denied.valid === false && denied.status === 'invalid', 'azure mocked 401 returns invalid')
+}
+
+// runProbe dispatch: cloud kinds no longer fall through to No probe implemented.
+{
+ const bedrock = await runProbe({ providerId: 'bedrock', apiKey: '' })
+ assert(bedrock.detail.includes('AWS error:'), 'runProbe dispatches bedrock to the aws probe')
+ const vertex = await runProbe({ providerId: 'vertex', apiKey: 'ya29.fake-token' })
+ assert(vertex.detail.includes('project and region'), 'runProbe dispatches vertex to the gcp probe')
+ const azure = await runProbe({ providerId: 'azure-openai', apiKey: 'fake-key' })
+ assert(azure.detail.includes('endpoint'), 'runProbe dispatches azure-openai to the azure probe')
+ const unknown = await runProbe({ providerId: 'no-such-provider', apiKey: 'x' })
+ assert(unknown.detail.includes('No probe implemented'), 'runProbe still reports unimplemented providers')
+}
+
+Object.assign(process.env, savedEnv)
+server.close()
+if (failed > 0) {
+ console.error(`\n${failed} cloud probe case(s) failed`)
+ process.exit(1)
+}
+console.log('\nall cloud probe cases passed')
diff --git a/src/main/probes/aws.ts b/src/main/probes/aws.ts
new file mode 100644
index 0000000..761bf4d
--- /dev/null
+++ b/src/main/probes/aws.ts
@@ -0,0 +1,147 @@
+import { createHash, createHmac } from 'node:crypto'
+import type { ProbeResult } from './types'
+
+const TIMEOUT_MS = 5000
+const SERVICE = 'bedrock'
+const DEFAULT_REGION = 'us-east-1'
+
+export interface AwsCredentials {
+ accessKeyId: string
+ secretAccessKey: string
+ sessionToken?: string
+}
+
+export interface BedrockProbeOptions {
+ /** Explicit credentials. Falls back to the AWS env chain when omitted. */
+ credentials?: AwsCredentials
+ /** Region override. Falls back to AWS_REGION / AWS_DEFAULT_REGION / us-east-1. */
+ region?: string
+ /** Endpoint override (tests). Defaults to the Bedrock control plane for the region. */
+ endpoint?: string
+ /** Clock override (tests) so the SigV4 signature is deterministic. */
+ now?: Date
+}
+
+function credentialsFromEnv(): AwsCredentials | null {
+ const accessKeyId = process.env.AWS_ACCESS_KEY_ID
+ const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY
+ if (!accessKeyId || !secretAccessKey) return null
+ return {
+ accessKeyId,
+ secretAccessKey,
+ sessionToken: process.env.AWS_SESSION_TOKEN,
+ }
+}
+
+function regionFromEnv(): string {
+ return process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? DEFAULT_REGION
+}
+
+function sha256Hex(value: string): string {
+ return createHash('sha256').update(value, 'utf8').digest('hex')
+}
+
+function hmac(key: Buffer | string, value: string): Buffer {
+ return createHmac('sha256', key).update(value, 'utf8').digest()
+}
+
+/** Sign a GET request with AWS Signature Version 4 for the bedrock service. */
+export function signBedrockRequest(opts: {
+ endpoint: string
+ region: string
+ credentials: AwsCredentials
+ now: Date
+}): { url: string; headers: Record } {
+ const url = new URL('/foundation-models', opts.endpoint)
+ const host = url.host
+ const amzDate = opts.now.toISOString().replace(/[:-]|\.\d{3}/g, '')
+ const dateStamp = amzDate.slice(0, 8)
+
+ const headers: Record = {
+ host,
+ 'x-amz-date': amzDate,
+ }
+ if (opts.credentials.sessionToken) {
+ headers['x-amz-security-token'] = opts.credentials.sessionToken
+ }
+ const signedHeaderNames = Object.keys(headers).sort()
+ const canonicalHeaders = signedHeaderNames.map((name) => `${name}:${headers[name]}\n`).join('')
+ const signedHeaders = signedHeaderNames.join(';')
+ const payloadHash = sha256Hex('')
+
+ const canonicalRequest = ['GET', url.pathname, '', canonicalHeaders, signedHeaders, payloadHash].join('\n')
+ const scope = `${dateStamp}/${opts.region}/${SERVICE}/aws4_request`
+ const stringToSign = ['AWS4-HMAC-SHA256', amzDate, scope, sha256Hex(canonicalRequest)].join('\n')
+
+ const kDate = hmac(`AWS4${opts.credentials.secretAccessKey}`, dateStamp)
+ const kRegion = hmac(kDate, opts.region)
+ const kService = hmac(kRegion, SERVICE)
+ const kSigning = hmac(kService, 'aws4_request')
+ const signature = createHmac('sha256', kSigning).update(stringToSign, 'utf8').digest('hex')
+
+ headers.Authorization =
+ `AWS4-HMAC-SHA256 Credential=${opts.credentials.accessKeyId}/${scope}, ` +
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`
+
+ return { url: url.toString(), headers }
+}
+
+/** Extract the AWS error code and message from an XML or JSON error body. */
+export function parseAwsError(body: string): { code?: string; message?: string } {
+ const code = body.match(/([^<]+)<\/Code>/)?.[1] ?? body.match(/"__type"\s*:\s*"[^"#]*#([^"]+)"/)?.[1]
+ const message =
+ body.match(/([^<]+)<\/Message>/)?.[1] ??
+ body.match(/"message"\s*:\s*"([^"]+)"/i)?.[1]
+ return { code, message }
+}
+
+export async function probeBedrock(opts: BedrockProbeOptions = {}): Promise {
+ const checkedAt = new Date().toISOString()
+ const credentials = opts.credentials ?? credentialsFromEnv()
+
+ if (!credentials) {
+ return {
+ valid: false,
+ status: 'invalid',
+ detail:
+ 'AWS error: NoCredentialProviders: no AWS credentials found. Set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, or configure the standard AWS credential chain.',
+ checkedAt,
+ }
+ }
+
+ const region = opts.region ?? regionFromEnv()
+ const endpoint = opts.endpoint ?? `https://bedrock.${region}.amazonaws.com`
+ const { url, headers } = signBedrockRequest({
+ endpoint,
+ region,
+ credentials,
+ now: opts.now ?? new Date(),
+ })
+
+ const controller = new AbortController()
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
+ try {
+ const res = await fetch(url, { method: 'GET', headers, signal: controller.signal })
+ if (res.ok) {
+ return { valid: true, status: 'ok', detail: 'Credentials validated against Bedrock ListFoundationModels.', checkedAt }
+ }
+ const body = await res.text().catch(() => '')
+ const { code, message } = parseAwsError(body)
+ const awsDetail = code ? `AWS error: ${code}${message ? `: ${message}` : ''}` : `AWS error: Unexpected HTTP ${res.status}.`
+ if (res.status === 429 || code === 'ThrottlingException') {
+ return { valid: true, status: 'quota_exceeded', detail: awsDetail, checkedAt }
+ }
+ if (res.status === 401 || res.status === 403) {
+ return { valid: false, status: 'invalid', detail: awsDetail, checkedAt }
+ }
+ return { valid: false, status: 'error', detail: awsDetail, checkedAt }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ if (message.includes('abort')) {
+ return { valid: false, status: 'error', detail: `Timed out after ${TIMEOUT_MS}ms.`, checkedAt }
+ }
+ return { valid: false, status: 'error', detail: `AWS error: ${message}`, checkedAt }
+ } finally {
+ clearTimeout(timer)
+ }
+}
diff --git a/src/main/probes/azure.ts b/src/main/probes/azure.ts
new file mode 100644
index 0000000..cbd81f6
--- /dev/null
+++ b/src/main/probes/azure.ts
@@ -0,0 +1,59 @@
+import type { ProbeResult } from './types'
+
+const TIMEOUT_MS = 5000
+const DEFAULT_API_VERSION = '2024-02-01'
+
+export interface AzureProbeOptions {
+ apiKey: string
+ /** Azure OpenAI resource endpoint, for example https://.openai.azure.com */
+ baseUrl?: string
+ apiVersion?: string
+}
+
+export async function probeAzure(opts: AzureProbeOptions): Promise {
+ const checkedAt = new Date().toISOString()
+
+ if (!opts.baseUrl) {
+ return {
+ valid: false,
+ status: 'error',
+ detail: 'Azure probe requires an endpoint. Set the Azure OpenAI endpoint (AZURE_OPENAI_ENDPOINT) for this key first.',
+ checkedAt,
+ }
+ }
+
+ if (!opts.apiKey) {
+ return { valid: false, status: 'invalid', detail: 'No API key supplied.', checkedAt }
+ }
+
+ const apiVersion = opts.apiVersion ?? DEFAULT_API_VERSION
+ const url = `${opts.baseUrl.replace(/\/$/, '')}/openai/deployments?api-version=${apiVersion}`
+
+ const controller = new AbortController()
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
+ try {
+ const res = await fetch(url, {
+ method: 'GET',
+ headers: { 'api-key': opts.apiKey },
+ signal: controller.signal,
+ })
+ if (res.ok) {
+ return { valid: true, status: 'ok', detail: 'Key validated against Azure OpenAI deployments.', checkedAt }
+ }
+ if (res.status === 401 || res.status === 403) {
+ return { valid: false, status: 'invalid', detail: `Authentication failed (${res.status}).`, checkedAt }
+ }
+ if (res.status === 429) {
+ return { valid: true, status: 'quota_exceeded', detail: 'Rate limited; credential accepted.', checkedAt }
+ }
+ return { valid: false, status: 'error', detail: `Unexpected HTTP ${res.status}.`, checkedAt }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ if (message.includes('abort')) {
+ return { valid: false, status: 'error', detail: `Timed out after ${TIMEOUT_MS}ms.`, checkedAt }
+ }
+ return { valid: false, status: 'error', detail: message, checkedAt }
+ } finally {
+ clearTimeout(timer)
+ }
+}
diff --git a/src/main/probes/gcp.ts b/src/main/probes/gcp.ts
new file mode 100644
index 0000000..dcb5a79
--- /dev/null
+++ b/src/main/probes/gcp.ts
@@ -0,0 +1,99 @@
+import type { ProbeResult } from './types'
+
+const TIMEOUT_MS = 5000
+const API_VERSION_PATH = 'publishers/google/models'
+
+export interface VertexProbeOptions {
+ /** OAuth2 access token (for example from ADC via gcloud) sent as Bearer. */
+ apiKey: string
+ /** GCP project id. Required; the probe errors without it. */
+ project?: string
+ /** Vertex region, for example us-central1 or global. Required. */
+ region?: string
+ /** Endpoint override (tests). Defaults to the regional aiplatform host. */
+ endpoint?: string
+}
+
+export function vertexModelsUrl(project: string, region: string, endpoint?: string): string {
+ const base =
+ endpoint ??
+ (region === 'global'
+ ? 'https://aiplatform.googleapis.com'
+ : `https://${region}-aiplatform.googleapis.com`)
+ return `${base.replace(/\/$/, '')}/v1/projects/${project}/locations/${region}/${API_VERSION_PATH}`
+}
+
+function parseGoogleError(body: string): string | undefined {
+ try {
+ const parsed = JSON.parse(body) as { error?: { status?: string; message?: string } }
+ if (parsed.error?.message) {
+ return parsed.error.status ? `${parsed.error.status}: ${parsed.error.message}` : parsed.error.message
+ }
+ } catch {
+ // fall through to the raw body snippet
+ }
+ return body.trim() ? body.trim().slice(0, 200) : undefined
+}
+
+export async function probeVertex(opts: VertexProbeOptions): Promise {
+ const checkedAt = new Date().toISOString()
+
+ if (!opts.project || !opts.region) {
+ return {
+ valid: false,
+ status: 'error',
+ detail: 'Vertex probe requires a GCP project and region. Save a project id and region for this key first.',
+ checkedAt,
+ }
+ }
+
+ if (!opts.apiKey) {
+ return {
+ valid: false,
+ status: 'invalid',
+ detail: 'No access token supplied. Run `gcloud auth application-default login` and try again.',
+ checkedAt,
+ }
+ }
+
+ const url = vertexModelsUrl(opts.project, opts.region, opts.endpoint)
+ const controller = new AbortController()
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
+ try {
+ const res = await fetch(url, {
+ method: 'GET',
+ headers: { Authorization: `Bearer ${opts.apiKey}` },
+ signal: controller.signal,
+ })
+ if (res.ok) {
+ return { valid: true, status: 'ok', detail: 'Credentials validated against Vertex AI publishers/google/models.', checkedAt }
+ }
+ const body = await res.text().catch(() => '')
+ const googleDetail = parseGoogleError(body)
+ if (res.status === 401 || res.status === 403) {
+ return {
+ valid: false,
+ status: 'invalid',
+ detail: googleDetail ? `Vertex error: ${googleDetail}` : `Authentication failed (${res.status}).`,
+ checkedAt,
+ }
+ }
+ if (res.status === 429) {
+ return { valid: true, status: 'quota_exceeded', detail: 'Vertex rate limited; credential accepted.', checkedAt }
+ }
+ return {
+ valid: false,
+ status: 'error',
+ detail: googleDetail ? `Vertex error: ${googleDetail}` : `Unexpected HTTP ${res.status}.`,
+ checkedAt,
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ if (message.includes('abort')) {
+ return { valid: false, status: 'error', detail: `Timed out after ${TIMEOUT_MS}ms.`, checkedAt }
+ }
+ return { valid: false, status: 'error', detail: message, checkedAt }
+ } finally {
+ clearTimeout(timer)
+ }
+}
diff --git a/src/main/probes/index.ts b/src/main/probes/index.ts
index ad83e10..6ca3422 100644
--- a/src/main/probes/index.ts
+++ b/src/main/probes/index.ts
@@ -1,17 +1,30 @@
import { probeAnthropic } from './anthropic'
import { probeOpenAI } from './openai'
+import { probeBedrock } from './aws'
+import { probeVertex } from './gcp'
+import { probeAzure } from './azure'
import type { ProbeResult } from './types'
import { PROVIDER_CATALOG } from '../providers/catalog.generated'
export type { ProbeResult } from './types'
export type { AnthropicProbeOptions } from './anthropic'
+export type { BedrockProbeOptions, AwsCredentials } from './aws'
+export type { VertexProbeOptions } from './gcp'
+export type { AzureProbeOptions } from './azure'
export { probeAnthropic } from './anthropic'
export { probeOpenAI } from './openai'
+export { probeBedrock } from './aws'
+export { probeVertex } from './gcp'
+export { probeAzure } from './azure'
export interface ProbeContext {
providerId: string
apiKey: string
baseUrl?: string
+ /** GCP project id for the Vertex (gcp) probe. */
+ project?: string
+ /** Cloud region for the Vertex (gcp) and Bedrock (aws) probes. */
+ region?: string
}
/** Providers that share the OpenAI-compatible /models probe. */
@@ -46,6 +59,25 @@ export async function runProbe(ctx: ProbeContext): Promise {
})
}
+ if (kind === 'aws') {
+ return probeBedrock({ region: ctx.region })
+ }
+
+ if (kind === 'gcp') {
+ return probeVertex({
+ apiKey: ctx.apiKey,
+ project: ctx.project,
+ region: ctx.region,
+ })
+ }
+
+ if (kind === 'azure') {
+ return probeAzure({
+ apiKey: ctx.apiKey,
+ baseUrl: ctx.baseUrl ?? provider?.defaultBaseUrl,
+ })
+ }
+
return {
valid: false,
status: 'error',
diff --git a/src/main/providers/catalog.generated.ts b/src/main/providers/catalog.generated.ts
index f05aedd..eaf6165 100644
--- a/src/main/providers/catalog.generated.ts
+++ b/src/main/providers/catalog.generated.ts
@@ -5,7 +5,7 @@
* src/main/providers/catalog.source.json
* and run `npm run gen:catalog`.
*
- * fingerprint: 3d35ef2c7f6a58e0 (entries: 18)
+ * fingerprint: e96a0df0e32f36db (entries: 18)
*/
import type { ProviderEntry } from './types'
@@ -68,7 +68,7 @@ export const PROVIDER_CATALOG: readonly ProviderEntry[] =
"envKeys": [],
"authType": "cloud_creds",
"probeKind": "gcp",
- "notes": "Uses ADC. Configure project + region in your gateway."
+ "notes": "Uses ADC. Wizard will ask for project + region first."
},
{
"id": "bedrock",
@@ -79,7 +79,7 @@ export const PROVIDER_CATALOG: readonly ProviderEntry[] =
"envKeys": [],
"authType": "cloud_creds",
"probeKind": "aws",
- "notes": "Uses AWS credentials from the standard chain."
+ "notes": "Uses AWS credentials from the standard chain. Wizard validates them with a Bedrock ListFoundationModels call."
},
{
"id": "azure-openai",
diff --git a/src/main/providers/catalog.source.json b/src/main/providers/catalog.source.json
index 6b861d6..e565325 100644
--- a/src/main/providers/catalog.source.json
+++ b/src/main/providers/catalog.source.json
@@ -41,7 +41,7 @@
"envKeys": [],
"authType": "cloud_creds",
"probeKind": "gcp",
- "notes": "Uses ADC. Configure project + region in your gateway."
+ "notes": "Uses ADC. Wizard will ask for project + region first."
},
{
"id": "bedrock",
@@ -50,7 +50,7 @@
"envKeys": [],
"authType": "cloud_creds",
"probeKind": "aws",
- "notes": "Uses AWS credentials from the standard chain."
+ "notes": "Uses AWS credentials from the standard chain. Wizard validates them with a Bedrock ListFoundationModels call."
},
{
"id": "azure-openai",