From 4c5cd0a6780407fff845c0bcb62c5ed7ab9ff0a1 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 26 Jul 2026 14:07:35 +0300 Subject: [PATCH] refactor(ai): register direct model providers - Move provider-specific model construction behind typed adapters - Keep OpenAI- and Anthropic-compatible configuration reusable - Verify every direct provider is registered while ACP agents stay separate --- src/app/ai/chat/model.ts | 103 +++-------------------------- src/app/ai/providers/compatible.ts | 56 ++++++++++++++++ src/app/ai/providers/registry.ts | 63 ++++++++++++++++++ src/app/ai/providers/types.ts | 20 ++++++ tests/engine/app/ai/model.test.ts | 14 ++++ 5 files changed, 164 insertions(+), 92 deletions(-) create mode 100644 src/app/ai/providers/compatible.ts create mode 100644 src/app/ai/providers/registry.ts create mode 100644 src/app/ai/providers/types.ts diff --git a/src/app/ai/chat/model.ts b/src/app/ai/chat/model.ts index 3ea771d59..62f35f7a0 100644 --- a/src/app/ai/chat/model.ts +++ b/src/app/ai/chat/model.ts @@ -1,31 +1,21 @@ -import { createAnthropic } from '@ai-sdk/anthropic' -import { createDeepSeek } from '@ai-sdk/deepseek' -import { createGoogleGenerativeAI } from '@ai-sdk/google' -import { createOpenAI } from '@ai-sdk/openai' -import { createOpenRouter } from '@openrouter/ai-sdk-provider' import type { LanguageModel } from 'ai' -import type { AIProviderID } from '@open-pencil/core/constants' - +import { modelProviderAdapter } from '@/app/ai/providers/registry' +import type { ModelConfig } from '@/app/ai/providers/types' import { isTauri } from '@/app/tauri/env' import { tauriFetch } from '@/app/tauri/http' -export type ModelConfig = { - providerID: AIProviderID - apiKey: string - modelID: string - customModelID: string - customBaseURL: string - customAPIType: 'completions' | 'responses' -} +export type { ModelConfig } from '@/app/ai/providers/types' export function resolveLanguageModelID( config: Pick -) { - const customModelID = config.customModelID.trim() - if (config.providerID === 'openrouter') return customModelID || config.modelID - if (config.providerID === 'openai-compatible' || config.providerID === 'anthropic-compatible') { - return customModelID +): string { + if ( + config.providerID === 'openrouter' || + config.providerID === 'openai-compatible' || + config.providerID === 'anthropic-compatible' + ) { + return config.customModelID.trim() || config.modelID } return config.modelID } @@ -35,76 +25,5 @@ function desktopFetch(): typeof fetch | undefined { } export function createLanguageModel(config: ModelConfig): LanguageModel { - const effectiveModelID = resolveLanguageModelID(config) - const fetch = desktopFetch() - - switch (config.providerID) { - case 'openrouter': { - const openrouter = createOpenRouter({ - apiKey: config.apiKey, - fetch, - headers: { - 'X-OpenRouter-Title': 'OpenPencil', - 'HTTP-Referer': 'https://github.com/open-pencil/open-pencil' - } - }) - return openrouter(effectiveModelID) - } - case 'anthropic': { - const anthropic = createAnthropic({ apiKey: config.apiKey, fetch }) - return anthropic(effectiveModelID) - } - case 'openai': { - const openai = createOpenAI({ apiKey: config.apiKey, fetch }) - return openai(effectiveModelID) - } - case 'google': { - const google = createGoogleGenerativeAI({ apiKey: config.apiKey, fetch }) - return google(effectiveModelID) - } - case 'deepseek': { - const deepseek = createDeepSeek({ apiKey: config.apiKey, fetch }) - return deepseek(effectiveModelID) - } - case 'zai': { - const zai = createAnthropic({ - apiKey: config.apiKey, - baseURL: 'https://api.z.ai/api/anthropic', - fetch - }) - return zai(effectiveModelID) - } - case 'minimax': { - const minimax = createOpenAI({ - apiKey: config.apiKey, - baseURL: 'https://api.minimax.io/v1', - fetch - }) - return minimax.chat(effectiveModelID) - } - case 'openai-compatible': { - const custom = createOpenAI({ - apiKey: config.apiKey, - baseURL: config.customBaseURL, - fetch - }) - return config.customAPIType === 'responses' - ? custom.responses(effectiveModelID) - : custom.chat(effectiveModelID) - } - case 'anthropic-compatible': { - const custom = createAnthropic({ - apiKey: config.apiKey, - baseURL: config.customBaseURL, - fetch - }) - return custom(effectiveModelID) - } - default: { - if (config.providerID.startsWith('acp:')) { - throw new Error('ACP providers do not use direct API models') - } - throw new Error(`Unknown provider: ${config.providerID}`) - } - } + return modelProviderAdapter(config.providerID).create(config, { fetch: desktopFetch() }) } diff --git a/src/app/ai/providers/compatible.ts b/src/app/ai/providers/compatible.ts new file mode 100644 index 000000000..19ba9fdf4 --- /dev/null +++ b/src/app/ai/providers/compatible.ts @@ -0,0 +1,56 @@ +import { createAnthropic } from '@ai-sdk/anthropic' +import { createOpenAI } from '@ai-sdk/openai' + +import type { ModelConfig, ModelProviderAdapter } from '@/app/ai/providers/types' + +export type CompatibleEndpoint = string | ((config: ModelConfig) => string) + +type OpenAICompatibleOptions = { + baseURL?: CompatibleEndpoint + mode?: 'default' | 'chat' | 'configurable' +} + +type AnthropicCompatibleOptions = { + baseURL?: CompatibleEndpoint +} + +function resolveEndpoint(endpoint: CompatibleEndpoint | undefined, config: ModelConfig) { + return typeof endpoint === 'function' ? endpoint(config) : endpoint +} + +export function createOpenAICompatibleAdapter( + options: OpenAICompatibleOptions = {} +): ModelProviderAdapter { + return { + create(config, runtime) { + const provider = createOpenAI({ + apiKey: config.apiKey, + baseURL: resolveEndpoint(options.baseURL, config), + fetch: runtime.fetch + }) + const modelID = config.customModelID.trim() || config.modelID + if (options.mode === 'chat') return provider.chat(modelID) + if (options.mode === 'configurable') { + return config.customAPIType === 'responses' + ? provider.responses(modelID) + : provider.chat(modelID) + } + return provider(modelID) + } + } +} + +export function createAnthropicCompatibleAdapter( + options: AnthropicCompatibleOptions = {} +): ModelProviderAdapter { + return { + create(config, runtime) { + const provider = createAnthropic({ + apiKey: config.apiKey, + baseURL: resolveEndpoint(options.baseURL, config), + fetch: runtime.fetch + }) + return provider(config.customModelID.trim() || config.modelID) + } + } +} diff --git a/src/app/ai/providers/registry.ts b/src/app/ai/providers/registry.ts new file mode 100644 index 000000000..7339a1f36 --- /dev/null +++ b/src/app/ai/providers/registry.ts @@ -0,0 +1,63 @@ +import { createDeepSeek } from '@ai-sdk/deepseek' +import { createGoogleGenerativeAI } from '@ai-sdk/google' +import { createOpenRouter } from '@openrouter/ai-sdk-provider' + +import type { AIProviderID } from '@open-pencil/core/constants' + +import { + createAnthropicCompatibleAdapter, + createOpenAICompatibleAdapter +} from '@/app/ai/providers/compatible' +import type { ModelProviderAdapter } from '@/app/ai/providers/types' + +type DirectProviderID = Exclude + +const MODEL_PROVIDER_ADAPTERS = { + openrouter: { + create(config, runtime) { + const provider = createOpenRouter({ + apiKey: config.apiKey, + fetch: runtime.fetch, + headers: { + 'X-OpenRouter-Title': 'OpenPencil', + 'HTTP-Referer': 'https://github.com/open-pencil/open-pencil' + } + }) + return provider(config.customModelID.trim() || config.modelID) + } + }, + anthropic: createAnthropicCompatibleAdapter(), + openai: createOpenAICompatibleAdapter(), + google: { + create(config, runtime) { + return createGoogleGenerativeAI({ apiKey: config.apiKey, fetch: runtime.fetch })( + config.modelID + ) + } + }, + deepseek: { + create(config, runtime) { + return createDeepSeek({ apiKey: config.apiKey, fetch: runtime.fetch })(config.modelID) + } + }, + zai: createAnthropicCompatibleAdapter({ baseURL: 'https://api.z.ai/api/anthropic' }), + minimax: createOpenAICompatibleAdapter({ baseURL: 'https://api.minimax.io/v1', mode: 'chat' }), + 'openai-compatible': createOpenAICompatibleAdapter({ + baseURL: (config) => config.customBaseURL, + mode: 'configurable' + }), + 'anthropic-compatible': createAnthropicCompatibleAdapter({ + baseURL: (config) => config.customBaseURL + }) +} satisfies Record + +function isDirectProviderID(providerID: AIProviderID): providerID is DirectProviderID { + return !providerID.startsWith('acp:') +} + +export function modelProviderAdapter(providerID: AIProviderID): ModelProviderAdapter { + if (!isDirectProviderID(providerID)) { + throw new Error('ACP providers do not use direct API models') + } + return MODEL_PROVIDER_ADAPTERS[providerID] +} diff --git a/src/app/ai/providers/types.ts b/src/app/ai/providers/types.ts new file mode 100644 index 000000000..bdfaf2692 --- /dev/null +++ b/src/app/ai/providers/types.ts @@ -0,0 +1,20 @@ +import type { LanguageModel } from 'ai' + +import type { AIProviderID } from '@open-pencil/core/constants' + +export type ModelConfig = { + providerID: AIProviderID + apiKey: string + modelID: string + customModelID: string + customBaseURL: string + customAPIType: 'completions' | 'responses' +} + +export type ModelProviderRuntime = { + fetch?: typeof fetch +} + +export interface ModelProviderAdapter { + create(config: ModelConfig, runtime: ModelProviderRuntime): LanguageModel +} diff --git a/tests/engine/app/ai/model.test.ts b/tests/engine/app/ai/model.test.ts index 16e74a046..fcb30f1f5 100644 --- a/tests/engine/app/ai/model.test.ts +++ b/tests/engine/app/ai/model.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from 'bun:test' +import { AI_PROVIDERS } from '@open-pencil/core/constants' + import { resolveLanguageModelID } from '@/app/ai/chat/model' import { normalizeOpenRouterModel } from '@/app/ai/chat/provider-models' +import { modelProviderAdapter } from '@/app/ai/providers/registry' describe('resolveLanguageModelID', () => { test('uses the selected OpenRouter model when no custom model is configured', () => { @@ -25,6 +28,17 @@ describe('resolveLanguageModelID', () => { }) }) +describe('model provider registry', () => { + test('registers every direct provider without handling ACP agents as models', () => { + for (const provider of AI_PROVIDERS) { + expect(modelProviderAdapter(provider.id).create).toBeFunction() + } + expect(() => modelProviderAdapter('acp:claude-code')).toThrow( + 'ACP providers do not use direct API models' + ) + }) +}) + describe('normalizeOpenRouterModel', () => { test('keeps tool-capable OpenRouter models', () => { expect(