diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 07a53d29316..7cbbc1683ea 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -19,6 +19,16 @@ export function FormatError(input: unknown) { if (Provider.InitError.isInstance(input)) { return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.` } + if (Provider.HealthCheckError.isInstance(input)) { + const { providerID, baseURL, error } = input.data + return [ + `Health check failed for provider "${providerID}"`, + baseURL ? `Endpoint: ${baseURL}` : "", + `Error: ${error}`, + ] + .filter(Boolean) + .join("\n") + } if (Config.JsonError.isInstance(input)) { return ( `Config file at ${input.data.path} is not valid JSON(C)` + (input.data.message ? `: ${input.data.message}` : "") diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index be1949c3b05..70c055ac5b1 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -579,6 +579,14 @@ export namespace Config { whitelist: z.array(z.string()).optional(), blacklist: z.array(z.string()).optional(), models: z.record(z.string(), ModelsDev.Model.partial()).optional(), + healthCheck: z + .object({ + url: z.string().optional().describe("Health check endpoint URL. Defaults to baseURL + /models"), + timeout: z.number().int().positive().default(2000).describe("Health check timeout in milliseconds"), + enabled: z.boolean().default(true).describe("Enable health check for this provider"), + }) + .optional() + .describe("Health check configuration for provider connection validation"), options: z .object({ apiKey: z.string().optional(), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 60ce2297b9b..7c5a88812c0 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -407,6 +407,13 @@ export namespace Provider { key: z.string().optional(), options: z.record(z.string(), z.any()), models: z.record(z.string(), Model), + healthCheck: z + .object({ + url: z.string().optional(), + timeout: z.number().int().positive().optional(), + enabled: z.boolean().optional(), + }) + .optional(), }) .meta({ ref: "Provider", @@ -484,6 +491,27 @@ export namespace Provider { } } + async function checkProviderHealth( + baseURL: string, + healthCheck?: { url?: string; timeout?: number; enabled?: boolean }, + ): Promise<{ healthy: boolean; error?: string }> { + if (healthCheck?.enabled === false) return { healthy: true } + + const url = healthCheck?.url ?? `${baseURL.replace(/\/$/, "")}/models` + const timeout = healthCheck?.timeout ?? 2000 + + try { + const response = await fetch(url, { + method: "GET", + signal: AbortSignal.timeout(timeout), + }) + return { healthy: response.ok } + } catch (e) { + const error = e instanceof Error ? e.message : String(e) + return { healthy: false, error } + } + } + const state = Instance.state(async () => { using _ = log.time("state") const config = await Config.get() @@ -505,6 +533,7 @@ export namespace Provider { [providerID: string]: CustomModelLoader } = {} const sdk = new Map() + const healthCheckResults = new Map() log.info("init") @@ -547,6 +576,7 @@ export namespace Provider { options: mergeDeep(existing?.options ?? {}, provider.options ?? {}), source: "config", models: existing?.models ?? {}, + healthCheck: provider.healthCheck ?? existing?.healthCheck, } for (const [modelID, model] of Object.entries(provider.models ?? {})) { @@ -742,6 +772,7 @@ export namespace Provider { providers, sdk, modelLoaders, + healthCheckResults, } }) @@ -770,6 +801,23 @@ export namespace Provider { ...model.headers, } + const baseURL = options["baseURL"] || model.api.url + const isLocal = baseURL?.includes("127.0.0.1") || baseURL?.includes("localhost") + const healthCheckConfig = provider.healthCheck + + if ((isLocal || healthCheckConfig?.enabled !== false) && !s.healthCheckResults.has(model.providerID)) { + const health = await checkProviderHealth(baseURL, healthCheckConfig) + s.healthCheckResults.set(model.providerID, health) + + if (!health.healthy) { + log.warn("provider health check failed", { + providerID: model.providerID, + baseURL, + error: health.error, + }) + } + } + const key = Bun.hash.xxHash32(JSON.stringify({ npm: model.api.npm, options })) const existing = s.sdk.get(key) if (existing) return existing @@ -990,4 +1038,13 @@ export namespace Provider { providerID: z.string(), }), ) + + export const HealthCheckError = NamedError.create( + "ProviderHealthCheckError", + z.object({ + providerID: z.string(), + baseURL: z.string().optional(), + error: z.string(), + }), + ) }