From 047147c948737ab9091140966e69ae75ba3a7817 Mon Sep 17 00:00:00 2001 From: Lee Penkman Date: Tue, 16 Jun 2026 14:08:21 +1200 Subject: [PATCH] feat(providers): add OpenPaths provider Add OpenPaths (https://openpaths.io), an OpenAI-compatible model gateway, as a named provider. Mirrors the OpenRouter wiring exactly across the parallel provider lists (types, factory, config arrays) and reuses the generic OpenAI-compatible client. - types: add 'openpaths' to ProviderName, OpenPathsSettings, AutohandConfig.openpaths - providers: new OpenPathsProvider (base URL https://openpaths.io/v1, models endpoint /v1/models) - factory: openpaths case + getProviderNames/isValidProvider entries - config: validProviders, isModernConfig, getProviderConfig, defaultBaseUrlFor - tests: ProviderFactory coverage for openpaths Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config.ts | 6 ++ src/core/agent/ProviderConfigManager.ts | 3 + src/onboarding/setupWizard.ts | 2 + src/providers/OpenPathsProvider.ts | 85 +++++++++++++++++++++++++ src/providers/ProviderFactory.ts | 11 +++- src/types.ts | 7 +- tests/providers/ProviderFactory.test.ts | 29 +++++++++ 7 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 src/providers/OpenPathsProvider.ts diff --git a/src/config.ts b/src/config.ts index bd78cda..2dee33d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -28,6 +28,7 @@ const TOML_CONFIG_PATH = AUTOHAND_FILES.configToml; const YAML_CONFIG_PATH = AUTOHAND_FILES.configYaml; const YML_CONFIG_PATH = AUTOHAND_FILES.configYml; const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"; +const DEFAULT_OPENPATHS_URL = "https://openpaths.io/v1"; const DEFAULT_OLLAMA_URL = "http://localhost:11434"; const DEFAULT_LLAMACPP_URL = "http://localhost:8080"; const DEFAULT_OPENAI_URL = "https://api.openai.com/v1"; @@ -62,6 +63,7 @@ function normalizeProviderName(provider: unknown): ProviderName | undefined { const validProviders: readonly ProviderName[] = [ "openrouter", + "openpaths", "ollama", "llamacpp", "openai", @@ -646,6 +648,7 @@ function isModernConfig( ): config is AutohandConfig { return ( typeof (config as AutohandConfig).openrouter === "object" || + typeof (config as AutohandConfig).openpaths === "object" || typeof (config as AutohandConfig).ollama === "object" || typeof (config as AutohandConfig).llamacpp === "object" || typeof (config as AutohandConfig).openai === "object" || @@ -829,6 +832,7 @@ export function getProviderConfig( const configByProvider: Record = { openrouter: config.openrouter, + openpaths: config.openpaths, ollama: config.ollama, llamacpp: config.llamacpp, openai: config.openai, @@ -870,6 +874,7 @@ export function getProviderConfig( } } else if ( chosen === "openrouter" || + chosen === "openpaths" || chosen === "llmgateway" || chosen === "zai" || chosen === "nvidia" || @@ -912,6 +917,7 @@ function defaultBaseUrlFor( port?: number, ): string | undefined { if (provider === "openrouter") return DEFAULT_BASE_URL; + if (provider === "openpaths") return DEFAULT_OPENPATHS_URL; if (provider === "llmgateway") return DEFAULT_LLMGATEWAY_URL; if (provider === "zai") return DEFAULT_ZAI_URL; if (provider === "deepseek") return DEFAULT_DEEPSEEK_URL; diff --git a/src/core/agent/ProviderConfigManager.ts b/src/core/agent/ProviderConfigManager.ts index e6a1acf..491e842 100644 --- a/src/core/agent/ProviderConfigManager.ts +++ b/src/core/agent/ProviderConfigManager.ts @@ -2810,6 +2810,9 @@ export class ProviderConfigManager { openrouter: this.runtime.config.openrouter ?? (this.runtime.config.openrouter = { apiKey: "", model }), + openpaths: + this.runtime.config.openpaths ?? + (this.runtime.config.openpaths = { apiKey: "", model }), ollama: this.runtime.config.ollama ?? (this.runtime.config.ollama = { model }), llamacpp: diff --git a/src/onboarding/setupWizard.ts b/src/onboarding/setupWizard.ts index 4e0adac..5a9e421 100644 --- a/src/onboarding/setupWizard.ts +++ b/src/onboarding/setupWizard.ts @@ -2051,6 +2051,7 @@ export class SetupWizard { private getDefaultModel(provider: ProviderName): string { const defaults: Record = { openrouter: 'nvidia/nemotron-3-super-120b-a12b:free', + openpaths: 'openpaths/auto', openai: 'gpt-5.4', ollama: 'llama3.2:latest', llamacpp: 'local', @@ -2071,6 +2072,7 @@ export class SetupWizard { private getDefaultBaseUrl(provider: ProviderName): string { const urls: Record = { openrouter: 'https://openrouter.ai/api/v1', + openpaths: 'https://openpaths.io/v1', openai: 'https://api.openai.com/v1', ollama: 'http://localhost:11434', llamacpp: 'http://localhost:8080', diff --git a/src/providers/OpenPathsProvider.ts b/src/providers/OpenPathsProvider.ts new file mode 100644 index 0000000..c6b0670 --- /dev/null +++ b/src/providers/OpenPathsProvider.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Autohand AI LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpenRouterClient } from "./OpenRouterClient.js"; +import type { LLMProvider } from "./LLMProvider.js"; +import type { + LLMRequest, + LLMResponse, + OpenPathsSettings, + NetworkSettings, +} from "../types.js"; + +const OPENPATHS_BASE_URL = "https://openpaths.io/v1"; +const OPENPATHS_MODELS_URL = "https://openpaths.io/v1/models"; + +/** + * OpenPaths is an OpenAI-compatible model gateway (https://openpaths.io). + * It reuses the generic OpenAI-compatible client used by OpenRouter, pointed + * at the OpenPaths base URL. + */ +export class OpenPathsProvider implements LLMProvider { + private client: OpenRouterClient; + private model: string; + + constructor(config: OpenPathsSettings, networkSettings?: NetworkSettings) { + this.client = new OpenRouterClient( + { ...config, baseUrl: config.baseUrl ?? OPENPATHS_BASE_URL }, + networkSettings, + ); + this.model = config.model; + } + + getName(): string { + return "openpaths"; + } + + setModel(model: string): void { + this.model = model; + this.client.setDefaultModel(model); + } + + async listModels(): Promise { + try { + const response = await fetch(OPENPATHS_MODELS_URL, { + headers: { + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(10000), + }); + + if (response.ok) { + const data = (await response.json()) as { data?: Array<{ id?: string }> }; + const ids = (Array.isArray(data?.data) ? data.data : []) + .map((model) => model.id) + .filter((id): id is string => Boolean(id)); + + if (ids.length > 0) { + return ids; + } + } + } catch { + // Fall through to the static fallback list below. + } + + return [ + "openpaths/auto", + "openpaths/auto-code", + "openpaths/auto-fast", + "openpaths/auto-reasoning", + ]; + } + + async isAvailable(): Promise { + // For OpenPaths, we can't easily check without making a request + // Return true if we have an API key + return true; + } + + async complete(request: LLMRequest): Promise { + return this.client.complete(request); + } +} diff --git a/src/providers/ProviderFactory.ts b/src/providers/ProviderFactory.ts index 06154a5..077e6e8 100644 --- a/src/providers/ProviderFactory.ts +++ b/src/providers/ProviderFactory.ts @@ -10,6 +10,7 @@ import { OllamaProvider } from './OllamaProvider.js'; import { OpenAIProvider } from './OpenAIProvider.js'; import { LlamaCppProvider } from './LlamaCppProvider.js'; import { OpenRouterProvider } from './OpenRouterProvider.js'; +import { OpenPathsProvider } from './OpenPathsProvider.js'; import { MLXProvider } from './MLXProvider.js'; import { LLMGatewayProvider } from './LLMGatewayProvider.js'; import { AzureProvider } from './AzureProvider.js'; @@ -154,6 +155,12 @@ export class ProviderFactory { } return new BedrockProvider(config.bedrock); + case 'openpaths': + if (!config.openpaths) { + return new UnconfiguredProvider('openpaths'); + } + return new OpenPathsProvider(config.openpaths, config.network); + case 'openrouter': default: if (!config.openrouter) { @@ -169,7 +176,7 @@ export class ProviderFactory { */ static getProviderNames(config?: Pick | null): ProviderName[] { // Sorted DESC by display name: Z.ai, xAI, Vertex AI, NVIDIA, OpenRouter, OpenAI, Ollama, MLX, LLM Gateway, llama.cpp, DeepSeek, Cerebras, Bedrock, Azure - const providers: ProviderName[] = ['zai', 'xai', 'vertexai', 'nvidia', 'openrouter', 'openai', 'ollama', 'llmgateway', 'llamacpp', 'deepseek', 'cerebras', 'azure']; + const providers: ProviderName[] = ['zai', 'xai', 'vertexai', 'nvidia', 'openrouter', 'openpaths', 'openai', 'ollama', 'llmgateway', 'llamacpp', 'deepseek', 'cerebras', 'azure']; if (isAwsBedrockProviderEnabled(config)) { providers.splice(providers.indexOf('azure'), 0, 'bedrock'); } @@ -189,7 +196,7 @@ export class ProviderFactory { return false; } - const allProviders: ProviderName[] = ['openrouter', 'ollama', 'openai', 'llamacpp', 'mlx', 'llmgateway', 'azure', 'zai', 'vertexai', 'xai', 'cerebras', 'nvidia', 'deepseek', 'bedrock']; + const allProviders: ProviderName[] = ['openrouter', 'openpaths', 'ollama', 'openai', 'llamacpp', 'mlx', 'llmgateway', 'azure', 'zai', 'vertexai', 'xai', 'cerebras', 'nvidia', 'deepseek', 'bedrock']; return allProviders.includes(name as ProviderName); } } diff --git a/src/types.ts b/src/types.ts index f1b5207..64de35d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,7 +32,7 @@ type Primitive = string | number | boolean | null; export type MessageRole = 'system' | 'user' | 'assistant' | 'tool'; -export type ProviderName = 'openrouter' | 'ollama' | 'llamacpp' | 'openai' | 'mlx' | 'llmgateway' | 'azure' | 'zai' | 'vertexai' | 'xai' | 'cerebras' | 'nvidia' | 'deepseek' | 'bedrock'; +export type ProviderName = 'openrouter' | 'openpaths' | 'ollama' | 'llamacpp' | 'openai' | 'mlx' | 'llmgateway' | 'azure' | 'zai' | 'vertexai' | 'xai' | 'cerebras' | 'nvidia' | 'deepseek' | 'bedrock'; export type AzureAuthMethod = 'api-key' | 'entra-id' | 'managed-identity'; export type OpenAIAuthMode = 'api-key' | 'chatgpt'; @@ -56,6 +56,10 @@ export interface OpenRouterSettings extends ProviderSettings { apiKey: string; } +export interface OpenPathsSettings extends ProviderSettings { + apiKey: string; +} + export interface LLMGatewaySettings extends ProviderSettings { apiKey: string; } @@ -650,6 +654,7 @@ export interface ChromeConfigSettings { export interface AutohandConfig { provider?: ProviderName; openrouter?: OpenRouterSettings; + openpaths?: OpenPathsSettings; ollama?: ProviderSettings; llamacpp?: ProviderSettings; openai?: OpenAISettings; diff --git a/tests/providers/ProviderFactory.test.ts b/tests/providers/ProviderFactory.test.ts index 36f0b4c..e1ab26a 100644 --- a/tests/providers/ProviderFactory.test.ts +++ b/tests/providers/ProviderFactory.test.ts @@ -52,6 +52,7 @@ describe("ProviderFactory", () => { "vertexai", "nvidia", "openrouter", + "openpaths", "openai", "ollama", "llmgateway", @@ -194,6 +195,30 @@ describe("ProviderFactory", () => { expect(provider.getName()).toBe("openrouter"); }); + + it("should create OpenPathsProvider when openpaths is configured", () => { + const config: AutohandConfig = { + provider: "openpaths", + openpaths: { + apiKey: "test-key", + model: "openpaths/auto", + }, + }; + + const provider = ProviderFactory.create(config); + + expect(provider.getName()).toBe("openpaths"); + }); + + it("should return UnconfiguredProvider when openpaths config is missing", () => { + const config: AutohandConfig = { + provider: "openpaths", + }; + + const provider = ProviderFactory.create(config); + + expect(provider.getName()).toBe("unconfigured"); + }); }); describe("isValidProvider()", () => { @@ -201,6 +226,10 @@ describe("ProviderFactory", () => { expect(ProviderFactory.isValidProvider("openrouter")).toBe(true); }); + it("should return true for openpaths", () => { + expect(ProviderFactory.isValidProvider("openpaths")).toBe(true); + }); + it("should return true for ollama", () => { expect(ProviderFactory.isValidProvider("ollama")).toBe(true); });