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:
parent
95a920197e
commit
4c5cd0a678
|
|
@ -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() })
|
||||
}
|
||||
|
|
|
|||
56
src/app/ai/providers/compatible.ts
Normal file
56
src/app/ai/providers/compatible.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/app/ai/providers/registry.ts
Normal file
63
src/app/ai/providers/registry.ts
Normal 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]
|
||||
}
|
||||
20
src/app/ai/providers/types.ts
Normal file
20
src/app/ai/providers/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue