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
This commit is contained in:
Danila Poyarkov 2026-07-26 14:07:35 +03:00
parent 95a920197e
commit 4c5cd0a678
5 changed files with 164 additions and 92 deletions

View file

@ -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<ModelConfig, 'providerID' | 'modelID' | 'customModelID'>
) {
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() })
}

View file

@ -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)
}
}
}

View file

@ -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<AIProviderID, `acp:${string}`>
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<DirectProviderID, ModelProviderAdapter>
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]
}

View file

@ -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
}

View file

@ -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(