refactor: extract shared buildPiModel to @openchatlab/node-runtime

Move URL normalization helpers and PiModel construction into a shared
llm-builder module. Electron and server both delegate to the core
builder, with Electron adding optional headers and provider validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-05-17 14:11:49 +08:00
1 parent 9c2b578c87
commit 73569c69bf
6 files changed
+272 -188

No files matched your search

+5 -98
View File
@@ -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<PiApi> {
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<string, PiApi> = {
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,
}
})
}
// ==================== 远程模型列表获取 ====================
@@ -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)
})
})
+4
View File
@@ -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'
+128
View File
@@ -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<string, string>
}
/**
* 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<string, PiApi> = {
gemini: 'google-generative-ai',
anthropic: 'anthropic-messages',
}
export function buildPiModel(config: PiModelConfig, options?: BuildPiModelOptions): PiModel<PiApi> {
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,
}
}
+4
View File
@@ -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'
+2 -90
View File
@@ -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<PiApi> {
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<string, PiApi> = {
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)
}