diff --git a/electron/main/ai/llm/index.ts b/electron/main/ai/llm/index.ts index 69755ba5..4ce77802 100644 --- a/electron/main/ai/llm/index.ts +++ b/electron/main/ai/llm/index.ts @@ -13,7 +13,7 @@ import { aiLogger } from '../logger' import { resolveApiKey, writeAuthProfile } from '@openchatlab/config' import { buildChatLabUserAgentHeaders } from '../../utils/httpHeaders' import { t } from '../../i18n' -import { completeSimple, type PiModel, type PiApi } from '@openchatlab/node-runtime' +import { completeSimple, buildPiModel as buildPiModelCore, type PiModel, type PiApi } from '@openchatlab/node-runtime' // 新模型系统导出 export { BUILTIN_PROVIDERS, getBuiltinProviderById } from './provider-registry' @@ -424,106 +424,13 @@ export function getProviderInfo(provider: LLMProvider): ProviderInfo | null { // ==================== pi-ai Model 构建 ==================== -/** - * 规范化 Anthropic baseUrl:Anthropic SDK 内部会拼接 /v1/messages, - * 因此 baseUrl 不应包含 /v1 后缀,否则会导致 /v1/v1/messages 双重路径。 - */ -function normalizeAnthropicBaseUrl(url: string): string { - return url.replace(/\/v1\/?$/, '') -} - -/** - * 规范化 OpenAI Compatible baseUrl: - * 用户经常忘记在域名后加 /v1,OpenAI SDK 不会自动补全。 - * 如果 URL 没有以 /v1 结尾且路径部分为空或仅有 /,自动补上。 - * 已有具体路径(如 /api/v1、/proxy)的不做修改。 - */ -function normalizeOpenAICompatibleBaseUrl(url: string): string { - if (!url) return url - const trimmed = url.replace(/\/+$/, '') - if (trimmed.endsWith('/v1')) return trimmed - try { - const parsed = new URL(trimmed) - // 仅当路径为空或 "/" 时补全 /v1,避免破坏已有的自定义路径 - if (parsed.pathname === '/' || parsed.pathname === '') { - return trimmed + '/v1' - } - } catch { - // URL 解析失败,不做处理 - } - return trimmed -} - -const DEFAULT_CONTEXT_WINDOW = 128000 - export function buildPiModel(config: AIServiceConfig): PiModel { - const providerDef = getBuiltinProviderById(config.provider) - const providerInfo = getProviderInfo(config.provider) - const baseUrl = config.baseUrl || providerDef?.defaultBaseUrl || providerInfo?.defaultBaseUrl || '' - const modelId = config.model || providerInfo?.models?.[0]?.id || '' + validateProviderBaseUrl(config.provider, config.baseUrl) - validateProviderBaseUrl(config.provider, baseUrl) - - const modelDef = findModelDefinition(config.provider, modelId) - const contextWindow = modelDef?.contextWindow ?? DEFAULT_CONTEXT_WINDOW - - const BUILTIN_PROVIDER_API: Record = { - gemini: 'google-generative-ai', - anthropic: 'anthropic-messages', - } - - const apiFormat: PiApi = (config.apiFormat as PiApi) || BUILTIN_PROVIDER_API[config.provider] || 'openai-completions' - - if (apiFormat === 'google-generative-ai') { - return { - id: modelId, - name: modelId, - api: 'google-generative-ai', - provider: 'google', - baseUrl, - reasoning: false, - input: ['text'], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow, - maxTokens: config.maxTokens ?? 8192, - } - } - - if (apiFormat === 'anthropic-messages') { - return { - id: modelId, - name: modelId, - api: 'anthropic-messages', - provider: 'anthropic', - baseUrl: normalizeAnthropicBaseUrl(baseUrl), - reasoning: false, - input: ['text'], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow, - maxTokens: config.maxTokens ?? 8192, - } - } - - // openai-compatible + openai-completions/openai-responses:自动补全 /v1(用户经常忘记) - const resolvedBaseUrl = - config.provider === 'openai-compatible' && (apiFormat === 'openai-completions' || apiFormat === 'openai-responses') - ? normalizeOpenAICompatibleBaseUrl(baseUrl) - : baseUrl - - return { - id: modelId, - name: modelId, - api: apiFormat, - provider: config.provider, - baseUrl: resolvedBaseUrl, + return buildPiModelCore(config, { + findModelFn: findModelDefinition, headers: config.provider === 'openai-compatible' ? buildChatLabUserAgentHeaders() : undefined, - reasoning: config.isReasoningModel ?? false, - input: ['text'], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow, - maxTokens: config.maxTokens ?? 4096, - compat: config.disableThinking ? { thinkingFormat: 'qwen' } : undefined, - } + }) } // ==================== 远程模型列表获取 ==================== diff --git a/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts b/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts new file mode 100644 index 00000000..b64d29fe --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { buildPiModel, normalizeAnthropicBaseUrl, normalizeOpenAICompatibleBaseUrl } from '../llm-builder' + +describe('normalizeAnthropicBaseUrl', () => { + it('strips trailing /v1', () => { + assert.equal(normalizeAnthropicBaseUrl('https://api.anthropic.com/v1'), 'https://api.anthropic.com') + }) + + it('strips trailing /v1/', () => { + assert.equal(normalizeAnthropicBaseUrl('https://api.anthropic.com/v1/'), 'https://api.anthropic.com') + }) + + it('leaves URL without /v1 unchanged', () => { + assert.equal(normalizeAnthropicBaseUrl('https://api.anthropic.com'), 'https://api.anthropic.com') + }) +}) + +describe('normalizeOpenAICompatibleBaseUrl', () => { + it('appends /v1 when path is empty', () => { + assert.equal(normalizeOpenAICompatibleBaseUrl('https://api.example.com'), 'https://api.example.com/v1') + }) + + it('appends /v1 when path is /', () => { + assert.equal(normalizeOpenAICompatibleBaseUrl('https://api.example.com/'), 'https://api.example.com/v1') + }) + + it('does not append when already ends with /v1', () => { + assert.equal(normalizeOpenAICompatibleBaseUrl('https://api.example.com/v1'), 'https://api.example.com/v1') + }) + + it('does not modify URLs with custom paths', () => { + assert.equal(normalizeOpenAICompatibleBaseUrl('https://api.example.com/proxy'), 'https://api.example.com/proxy') + }) + + it('returns empty string for empty input', () => { + assert.equal(normalizeOpenAICompatibleBaseUrl(''), '') + }) +}) + +describe('buildPiModel', () => { + it('builds a Google Generative AI model', () => { + const model = buildPiModel({ + provider: 'gemini', + model: 'gemini-2.0-flash', + baseUrl: 'https://generativelanguage.googleapis.com', + }) + assert.equal(model.api, 'google-generative-ai') + assert.equal(model.provider, 'google') + assert.equal(model.id, 'gemini-2.0-flash') + }) + + it('builds an Anthropic model with normalized URL', () => { + const model = buildPiModel({ + provider: 'anthropic', + model: 'claude-sonnet-4-20250514', + baseUrl: 'https://api.anthropic.com/v1', + }) + assert.equal(model.api, 'anthropic-messages') + assert.equal(model.baseUrl, 'https://api.anthropic.com') + }) + + it('builds an OpenAI model with default apiFormat', () => { + const model = buildPiModel({ + provider: 'openai', + model: 'gpt-4o', + baseUrl: 'https://api.openai.com/v1', + }) + assert.equal(model.api, 'openai-completions') + }) + + it('normalizes openai-compatible URLs', () => { + const model = buildPiModel({ + provider: 'openai-compatible', + model: 'custom-model', + baseUrl: 'https://my-gateway.example.com', + apiFormat: 'openai-completions', + }) + assert.equal(model.baseUrl, 'https://my-gateway.example.com/v1') + }) + + it('passes custom headers via options', () => { + const model = buildPiModel( + { provider: 'openai-compatible', model: 'test', baseUrl: 'https://test.com/v1' }, + { headers: { 'X-Custom': 'value' } } + ) + assert.deepEqual(model.headers, { 'X-Custom': 'value' }) + }) + + it('sets compat.thinkingFormat when disableThinking is true', () => { + const model = buildPiModel({ + provider: 'openai', + model: 'test', + baseUrl: 'https://api.openai.com/v1', + disableThinking: true, + }) + assert.deepEqual(model.compat, { thinkingFormat: 'qwen' }) + }) + + it('sets reasoning flag from isReasoningModel', () => { + const model = buildPiModel({ + provider: 'openai', + model: 'o1', + baseUrl: 'https://api.openai.com/v1', + isReasoningModel: true, + }) + assert.equal(model.reasoning, true) + }) + + it('uses custom findModelFn for context window', () => { + const model = buildPiModel( + { provider: 'openai', model: 'custom', baseUrl: 'https://api.openai.com/v1' }, + { + findModelFn: (_providerId, _modelId) => ({ + id: 'custom', + providerId: 'openai', + name: 'Custom', + contextWindow: 32000, + capabilities: ['chat'] as const, + recommendedFor: ['chat'] as const, + status: 'stable' as const, + builtin: false, + editable: true, + }), + } + ) + assert.equal(model.contextWindow, 32000) + }) +}) diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts index 0172b2fe..540b3178 100644 --- a/packages/node-runtime/src/ai/index.ts +++ b/packages/node-runtime/src/ai/index.ts @@ -62,6 +62,10 @@ export type { PreprocessingPipelineOptions, PreprocessingPipelineResult } from ' export type { AgentCoreOptions, AgentCoreEvent, AgentCoreResult, AgentTokenUsage, SimpleHistoryMessage } from './agent' export { runAgentCore } from './agent' +// LLM Model Builder +export { buildPiModel, normalizeAnthropicBaseUrl, normalizeOpenAICompatibleBaseUrl } from './llm-builder' +export type { PiModelConfig, BuildPiModelOptions } from './llm-builder' + // Re-exports from @mariozechner/pi-agent-core export type { AgentTool, AgentToolResult } from '@mariozechner/pi-agent-core' diff --git a/packages/node-runtime/src/ai/llm-builder.ts b/packages/node-runtime/src/ai/llm-builder.ts new file mode 100644 index 00000000..d6e91338 --- /dev/null +++ b/packages/node-runtime/src/ai/llm-builder.ts @@ -0,0 +1,128 @@ +/** + * Shared PiModel builder — translates an AIServiceConfig-like + * object into a PiModel instance for @mariozechner/pi-ai. + * + * Used by both Electron and Server to eliminate duplicated + * URL normalization, apiFormat mapping, and model construction. + */ + +import { BUILTIN_PROVIDERS, getBuiltinModelsByProvider, BUILTIN_MODELS, type ModelDefinition } from '@openchatlab/core' +import type { Model as PiModel, Api as PiApi } from '@mariozechner/pi-ai' + +export interface PiModelConfig { + provider: string + model?: string + baseUrl?: string + maxTokens?: number + apiFormat?: string + disableThinking?: boolean + isReasoningModel?: boolean +} + +export interface BuildPiModelOptions { + /** Override model definition lookup (e.g. to include custom models). */ + findModelFn?: (providerId: string, modelId: string) => ModelDefinition | null + /** Extra headers injected into the PiModel (e.g. User-Agent). */ + headers?: Record +} + +/** + * Strip /v1 suffix from Anthropic baseUrl because the SDK + * internally appends /v1/messages. + */ +export function normalizeAnthropicBaseUrl(url: string): string { + return url.replace(/\/v1\/?$/, '') +} + +/** + * Auto-append /v1 to OpenAI-compatible URLs when the path is empty + * (users frequently forget this). + */ +export function normalizeOpenAICompatibleBaseUrl(url: string): string { + if (!url) return url + const trimmed = url.replace(/\/+$/, '') + if (trimmed.endsWith('/v1')) return trimmed + try { + const parsed = new URL(trimmed) + if (parsed.pathname === '/' || parsed.pathname === '') { + return trimmed + '/v1' + } + } catch { + // URL parse failure — return as-is + } + return trimmed +} + +const DEFAULT_CONTEXT_WINDOW = 128000 + +function defaultFindModel(providerId: string, modelId: string): ModelDefinition | null { + const forProvider = getBuiltinModelsByProvider(providerId) + return forProvider.find((m) => m.id === modelId) || BUILTIN_MODELS.find((m) => m.id === modelId) || null +} + +const BUILTIN_PROVIDER_API: Record = { + gemini: 'google-generative-ai', + anthropic: 'anthropic-messages', +} + +export function buildPiModel(config: PiModelConfig, options?: BuildPiModelOptions): PiModel { + const providerDef = BUILTIN_PROVIDERS.find((p) => p.id === config.provider) + const baseUrl = config.baseUrl || providerDef?.defaultBaseUrl || '' + const modelId = config.model || '' + + const findModel = options?.findModelFn ?? defaultFindModel + const modelDef = findModel(config.provider, modelId) + const contextWindow = modelDef?.contextWindow ?? DEFAULT_CONTEXT_WINDOW + + const apiFormat: PiApi = (config.apiFormat as PiApi) || BUILTIN_PROVIDER_API[config.provider] || 'openai-completions' + + if (apiFormat === 'google-generative-ai') { + return { + id: modelId, + name: modelId, + api: 'google-generative-ai', + provider: 'google', + baseUrl, + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens: config.maxTokens ?? 8192, + } + } + + if (apiFormat === 'anthropic-messages') { + return { + id: modelId, + name: modelId, + api: 'anthropic-messages', + provider: 'anthropic', + baseUrl: normalizeAnthropicBaseUrl(baseUrl), + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens: config.maxTokens ?? 8192, + } + } + + const resolvedBaseUrl = + config.provider === 'openai-compatible' && (apiFormat === 'openai-completions' || apiFormat === 'openai-responses') + ? normalizeOpenAICompatibleBaseUrl(baseUrl) + : baseUrl + + return { + id: modelId, + name: modelId, + api: apiFormat, + provider: config.provider, + baseUrl: resolvedBaseUrl, + headers: options?.headers, + reasoning: config.isReasoningModel ?? false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow, + maxTokens: config.maxTokens ?? 4096, + compat: config.disableThinking ? { thinkingFormat: 'qwen' } : undefined, + } +} diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 413bb8fd..55cf7f19 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -86,6 +86,10 @@ export type { export type { AgentCoreOptions, AgentCoreEvent, AgentCoreResult, AgentTokenUsage, SimpleHistoryMessage } from './ai' export { runAgentCore } from './ai' +// LLM Model Builder +export { buildPiModel, normalizeAnthropicBaseUrl, normalizeOpenAICompatibleBaseUrl } from './ai' +export type { PiModelConfig, BuildPiModelOptions } from './ai' + // Re-exports: @mariozechner/pi-agent-core & @mariozechner/pi-ai export type { AgentTool, AgentToolResult } from './ai' export { Type, completeSimple, streamSimple } from './ai' diff --git a/packages/server/src/ai/llm-config.ts b/packages/server/src/ai/llm-config.ts index e884b167..0f23a038 100644 --- a/packages/server/src/ai/llm-config.ts +++ b/packages/server/src/ai/llm-config.ts @@ -10,8 +10,7 @@ import * as fs from 'fs' import * as path from 'path' import type { PiModel, PiApi } from '@openchatlab/node-runtime' -import { BUILTIN_PROVIDERS, getBuiltinModelsByProvider, BUILTIN_MODELS } from '@openchatlab/core' -import type { ModelDefinition } from '@openchatlab/core' +import { buildPiModel as buildPiModelCore, type PiModelConfig } from '@openchatlab/node-runtime' import { resolveApiKey, writeAuthProfile } from '@openchatlab/config' import { randomUUID } from 'crypto' @@ -253,93 +252,6 @@ function loadRawConfigStore(aiDataDir: string): AIConfigStore { // ==================== PiModel Builder ==================== -const DEFAULT_CONTEXT_WINDOW = 128000 - -function findModelDefinition(providerId: string, modelId: string): ModelDefinition | null { - const builtinForProvider = getBuiltinModelsByProvider(providerId) - return builtinForProvider.find((m) => m.id === modelId) || BUILTIN_MODELS.find((m) => m.id === modelId) || null -} - -function normalizeAnthropicBaseUrl(url: string): string { - return url.replace(/\/v1\/?$/, '') -} - -function normalizeOpenAICompatibleBaseUrl(url: string): string { - if (!url) return url - const trimmed = url.replace(/\/+$/, '') - if (trimmed.endsWith('/v1')) return trimmed - try { - const parsed = new URL(trimmed) - if (parsed.pathname === '/' || parsed.pathname === '') { - return trimmed + '/v1' - } - } catch { - // ignore - } - return trimmed -} - export function buildPiModel(config: AIServiceConfig): PiModel { - const providerDef = BUILTIN_PROVIDERS.find((p) => p.id === config.provider) - const baseUrl = config.baseUrl || providerDef?.defaultBaseUrl || '' - const modelId = config.model || '' - - const modelDef = findModelDefinition(config.provider, modelId) - const contextWindow = modelDef?.contextWindow ?? DEFAULT_CONTEXT_WINDOW - - const BUILTIN_PROVIDER_API: Record = { - gemini: 'google-generative-ai', - anthropic: 'anthropic-messages', - } - - const apiFormat: PiApi = (config.apiFormat as PiApi) || BUILTIN_PROVIDER_API[config.provider] || 'openai-completions' - - if (apiFormat === 'google-generative-ai') { - return { - id: modelId, - name: modelId, - api: 'google-generative-ai', - provider: 'google', - baseUrl, - reasoning: false, - input: ['text'], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow, - maxTokens: config.maxTokens ?? 8192, - } - } - - if (apiFormat === 'anthropic-messages') { - return { - id: modelId, - name: modelId, - api: 'anthropic-messages', - provider: 'anthropic', - baseUrl: normalizeAnthropicBaseUrl(baseUrl), - reasoning: false, - input: ['text'], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow, - maxTokens: config.maxTokens ?? 8192, - } - } - - const resolvedBaseUrl = - config.provider === 'openai-compatible' && (apiFormat === 'openai-completions' || apiFormat === 'openai-responses') - ? normalizeOpenAICompatibleBaseUrl(baseUrl) - : baseUrl - - return { - id: modelId, - name: modelId, - api: apiFormat, - provider: config.provider, - baseUrl: resolvedBaseUrl, - reasoning: config.isReasoningModel ?? false, - input: ['text'], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow, - maxTokens: config.maxTokens ?? 4096, - compat: config.disableThinking ? { thinkingFormat: 'qwen' } : undefined, - } + return buildPiModelCore(config as PiModelConfig) }