mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
refactor: extract fetchRemoteModels + validateApiKey to @openchatlab/node-runtime
Move remote LLM API operations into shared module with optional headers and logger injection. Electron wraps with UA headers and aiLogger; server re-exports directly. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
73569c69bf
commit
226c053006
5 files changed
+210
-343
No files matched your search
@@ -66,6 +66,10 @@ export { runAgentCore } from './agent'
|
||||
export { buildPiModel, normalizeAnthropicBaseUrl, normalizeOpenAICompatibleBaseUrl } from './llm-builder'
|
||||
export type { PiModelConfig, BuildPiModelOptions } from './llm-builder'
|
||||
|
||||
// Remote LLM API
|
||||
export { fetchRemoteModels, validateApiKey } from './remote-api'
|
||||
export type { RemoteModel, FetchRemoteModelsResult, RemoteApiOptions } from './remote-api'
|
||||
|
||||
// Re-exports from @mariozechner/pi-agent-core
|
||||
export type { AgentTool, AgentToolResult } from '@mariozechner/pi-agent-core'
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Remote LLM API operations (platform-agnostic).
|
||||
*
|
||||
* - fetchRemoteModels: list available models from a provider's API
|
||||
* - validateApiKey: send a minimal completion request to verify key validity
|
||||
*/
|
||||
|
||||
import { BUILTIN_PROVIDERS } from '@openchatlab/core'
|
||||
import { completeSimple } from '@mariozechner/pi-ai'
|
||||
import { buildPiModel, normalizeOpenAICompatibleBaseUrl, type PiModelConfig } from './llm-builder'
|
||||
import type { Model as PiModel, Api as PiApi } from '@mariozechner/pi-ai'
|
||||
|
||||
export interface RemoteModel {
|
||||
id: string
|
||||
name: string
|
||||
ownedBy?: string
|
||||
contextWindow?: number
|
||||
}
|
||||
|
||||
export interface FetchRemoteModelsResult {
|
||||
success: boolean
|
||||
models?: RemoteModel[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface RemoteApiOptions {
|
||||
/** Extra headers for outgoing requests (e.g. User-Agent). */
|
||||
headers?: Record<string, string>
|
||||
/** Optional logger. */
|
||||
onLog?: (level: 'info' | 'error', tag: string, message: string, data?: unknown) => void
|
||||
}
|
||||
|
||||
export async function fetchRemoteModels(
|
||||
provider: string,
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
apiFormat?: string,
|
||||
options?: RemoteApiOptions
|
||||
): Promise<FetchRemoteModelsResult> {
|
||||
const effectiveApiFormat = apiFormat || 'openai-completions'
|
||||
|
||||
if (effectiveApiFormat === 'anthropic-messages') {
|
||||
return { success: false, error: 'Anthropic does not support model listing via API' }
|
||||
}
|
||||
|
||||
const providerDef = BUILTIN_PROVIDERS.find((p) => p.id === provider)
|
||||
const rawBaseUrl = baseUrl || providerDef?.defaultBaseUrl || ''
|
||||
if (!rawBaseUrl) {
|
||||
return { success: false, error: 'No base URL provided' }
|
||||
}
|
||||
|
||||
const abortController = new AbortController()
|
||||
const timeout = setTimeout(() => abortController.abort(), 15000)
|
||||
|
||||
try {
|
||||
let url: string
|
||||
const headers: Record<string, string> = { ...options?.headers }
|
||||
|
||||
if (effectiveApiFormat === 'google-generative-ai') {
|
||||
const trimmed = rawBaseUrl.replace(/\/+$/, '').replace(/\/v1(beta)?$/, '')
|
||||
url = `${trimmed}/v1beta/models?key=${apiKey}`
|
||||
} else {
|
||||
const resolved = normalizeOpenAICompatibleBaseUrl(rawBaseUrl)
|
||||
url = `${resolved}/models`
|
||||
headers['Authorization'] = `Bearer ${apiKey}`
|
||||
}
|
||||
|
||||
options?.onLog?.('info', 'LLM', 'Fetching remote models', {
|
||||
url: url.replace(/key=[^&]+/, 'key=***'),
|
||||
provider,
|
||||
})
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '')
|
||||
return { success: false, error: `HTTP ${response.status}: ${body.slice(0, 200)}` }
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
|
||||
let models: RemoteModel[]
|
||||
|
||||
if (effectiveApiFormat === 'google-generative-ai') {
|
||||
const geminiModels = (json.models || []) as Array<{
|
||||
name?: string
|
||||
displayName?: string
|
||||
inputTokenLimit?: number
|
||||
}>
|
||||
models = geminiModels.map((m) => {
|
||||
const id = (m.name || '').replace(/^models\//, '')
|
||||
return {
|
||||
id,
|
||||
name: m.displayName || id,
|
||||
ownedBy: 'google',
|
||||
contextWindow: m.inputTokenLimit || undefined,
|
||||
}
|
||||
})
|
||||
} else {
|
||||
const data = (json.data || []) as Array<{
|
||||
id?: string
|
||||
owned_by?: string
|
||||
context_length?: number
|
||||
}>
|
||||
models = data
|
||||
.filter((m) => m.id)
|
||||
.map((m) => ({
|
||||
id: m.id!,
|
||||
name: m.id!,
|
||||
ownedBy: m.owned_by,
|
||||
contextWindow: m.context_length || undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
options?.onLog?.('info', 'LLM', `Fetched ${models.length} remote models`, { provider })
|
||||
return { success: true, models }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message.includes('aborted') || message.includes('AbortError')) {
|
||||
return { success: false, error: 'Request timed out (15s)' }
|
||||
}
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateApiKey(
|
||||
provider: string,
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
model?: string,
|
||||
apiFormat?: string,
|
||||
options?: RemoteApiOptions
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const providerDef = BUILTIN_PROVIDERS.find((p) => p.id === provider)
|
||||
const defaultModel = providerDef?.modelIds?.[0]
|
||||
|
||||
const config: PiModelConfig = {
|
||||
provider,
|
||||
model: model || defaultModel,
|
||||
baseUrl,
|
||||
apiFormat,
|
||||
}
|
||||
const piModel: PiModel<PiApi> = buildPiModel(config, { headers: options?.headers })
|
||||
|
||||
const abortController = new AbortController()
|
||||
const timeout = setTimeout(() => abortController.abort(), 15000)
|
||||
|
||||
try {
|
||||
const result = await completeSimple(
|
||||
piModel,
|
||||
{ messages: [{ role: 'user', content: 'Hi', timestamp: Date.now() }] as any },
|
||||
{ apiKey, maxTokens: 1, signal: abortController.signal }
|
||||
)
|
||||
if (result.stopReason === 'error' || result.stopReason === 'aborted') {
|
||||
return { success: false, error: result.errorMessage || 'Connection failed' }
|
||||
}
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message.includes('aborted') || message.includes('AbortError')) {
|
||||
return { success: false, error: 'Request timed out (15s)' }
|
||||
}
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,10 @@ export { runAgentCore } from './ai'
|
||||
export { buildPiModel, normalizeAnthropicBaseUrl, normalizeOpenAICompatibleBaseUrl } from './ai'
|
||||
export type { PiModelConfig, BuildPiModelOptions } from './ai'
|
||||
|
||||
// Remote LLM API
|
||||
export { fetchRemoteModels, validateApiKey } from './ai'
|
||||
export type { RemoteModel, FetchRemoteModelsResult, RemoteApiOptions } from './ai'
|
||||
|
||||
// Re-exports: @mariozechner/pi-agent-core & @mariozechner/pi-ai
|
||||
export type { AgentTool, AgentToolResult } from './ai'
|
||||
export { Type, completeSimple, streamSimple } from './ai'
|
||||
|
||||
Reference in new issue
Block a user