mirror of
https://github.com/moeru-ai/airi.git
synced 2026-07-10 00:08:33 +00:00
Merge 3259b7beba into 8c6e4576e1
This commit is contained in:
commit
e2a02dfd01
9 changed files with 291 additions and 5 deletions
|
|
@ -33,6 +33,7 @@ import { setupServerChannel } from './services/airi/channel-server'
|
|||
import { setupGodotStageManager } from './services/airi/godot-stage'
|
||||
import { setupBuiltInServer } from './services/airi/http-server'
|
||||
import { setupMcpStdioManager } from './services/airi/mcp-servers'
|
||||
import { setupOpenAICompatibleFetchBridge } from './services/airi/openai-compatible-fetch'
|
||||
import { setupExtensionHost } from './services/airi/plugins'
|
||||
import { setupArtistryBridge } from './services/airi/widgets/artistry-bridge'
|
||||
import { setupAutoUpdater } from './services/electron/auto-updater'
|
||||
|
|
@ -259,6 +260,7 @@ app.whenReady().then(async () => {
|
|||
context,
|
||||
artistryConfig: deps.artistryConfig,
|
||||
})
|
||||
setupOpenAICompatibleFetchBridge({ context })
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import type { createContext as createMainEventaContext } from '@moeru/eventa/adapters/electron/main'
|
||||
|
||||
import { defineStreamInvokeHandler } from '@moeru/eventa'
|
||||
import { openAICompatibleFetch } from '@proj-airi/stage-shared'
|
||||
|
||||
function normalizeRequestMethod(method: string | undefined): string {
|
||||
return (method ?? 'GET').toUpperCase()
|
||||
}
|
||||
|
||||
function normalizePathname(pathname: string): string {
|
||||
return pathname.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function isAllowedOpenAICompatiblePath(url: URL, method: string): boolean {
|
||||
const pathname = normalizePathname(url.pathname)
|
||||
if ((method === 'GET' || method === 'HEAD') && pathname.endsWith('/models'))
|
||||
return true
|
||||
|
||||
return method === 'POST' && pathname.endsWith('/chat/completions')
|
||||
}
|
||||
|
||||
function isWithinBaseUrl(url: URL, baseUrl: URL): boolean {
|
||||
if (url.origin !== baseUrl.origin)
|
||||
return false
|
||||
|
||||
const basePathname = baseUrl.pathname.endsWith('/') ? baseUrl.pathname : `${baseUrl.pathname}/`
|
||||
return url.pathname.startsWith(basePathname)
|
||||
}
|
||||
|
||||
function assertAllowedBridgeTarget(url: URL, baseUrl: URL, method: string) {
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new Error('OpenAI-compatible fetch only supports http and https URLs.')
|
||||
}
|
||||
if (baseUrl.protocol !== 'http:' && baseUrl.protocol !== 'https:') {
|
||||
throw new Error('OpenAI-compatible fetch only supports http and https base URLs.')
|
||||
}
|
||||
if (!isWithinBaseUrl(url, baseUrl)) {
|
||||
throw new Error('OpenAI-compatible fetch bridge only supports requests within the configured provider base URL.')
|
||||
}
|
||||
if (!isAllowedOpenAICompatiblePath(url, method)) {
|
||||
throw new Error('OpenAI-compatible fetch bridge only supports model listing and chat completions requests.')
|
||||
}
|
||||
}
|
||||
|
||||
export function setupOpenAICompatibleFetchBridge(params: {
|
||||
context: ReturnType<typeof createMainEventaContext>['context']
|
||||
}) {
|
||||
defineStreamInvokeHandler(params.context, openAICompatibleFetch, async function* (payload, options) {
|
||||
const url = new URL(payload.url)
|
||||
const baseUrl = new URL(payload.baseUrl)
|
||||
const method = normalizeRequestMethod(payload.method)
|
||||
assertAllowedBridgeTarget(url, baseUrl, method)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: payload.headers,
|
||||
body: payload.body,
|
||||
signal: options?.abortController?.signal,
|
||||
})
|
||||
|
||||
yield {
|
||||
type: 'head' as const,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
}
|
||||
|
||||
if (!response.body)
|
||||
return
|
||||
|
||||
const reader = response.body.getReader()
|
||||
let completed = false
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
completed = true
|
||||
return
|
||||
}
|
||||
|
||||
yield { type: 'chunk' as const, chunk: value }
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if (!completed) {
|
||||
try {
|
||||
await reader.cancel()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
reader.releaseLock()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ export * from './env-vars'
|
|||
export * from './environment'
|
||||
export * from './error-message'
|
||||
export * from './export-csv'
|
||||
export * from './openai-compatible'
|
||||
export * from './perf/io-trace'
|
||||
export * from './perf/tracer'
|
||||
export * from './url'
|
||||
|
|
|
|||
25
packages/stage-shared/src/openai-compatible.ts
Normal file
25
packages/stage-shared/src/openai-compatible.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { defineInvokeEventa } from '@moeru/eventa'
|
||||
|
||||
export interface OpenAICompatibleFetchRequest {
|
||||
url: string
|
||||
baseUrl: string
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
body?: string
|
||||
}
|
||||
|
||||
export interface OpenAICompatibleFetchResponse {
|
||||
type: 'head'
|
||||
status: number
|
||||
statusText: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
export interface OpenAICompatibleFetchChunk {
|
||||
type: 'chunk'
|
||||
chunk: Uint8Array
|
||||
}
|
||||
|
||||
export type OpenAICompatibleFetchStreamEvent = OpenAICompatibleFetchResponse | OpenAICompatibleFetchChunk
|
||||
|
||||
export const openAICompatibleFetch = defineInvokeEventa<OpenAICompatibleFetchStreamEvent, OpenAICompatibleFetchRequest>('eventa:invoke:electron:openai-compatible:fetch')
|
||||
135
packages/stage-ui/src/libs/providers/openaiCompatibleFetch.ts
Normal file
135
packages/stage-ui/src/libs/providers/openaiCompatibleFetch.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import type { Fetch } from '@xsai/shared'
|
||||
|
||||
import { defineStreamInvoke } from '@moeru/eventa'
|
||||
import { createContext } from '@moeru/eventa/adapters/electron/renderer'
|
||||
import { openAICompatibleFetch } from '@proj-airi/stage-shared'
|
||||
|
||||
type IpcRendererLike = Parameters<typeof createContext>[0]
|
||||
|
||||
const sharedFetches = new Map<string, Fetch>()
|
||||
|
||||
function resolveElectronIpcRenderer(): IpcRendererLike | undefined {
|
||||
if (typeof window === 'undefined')
|
||||
return undefined
|
||||
|
||||
return (window as { electron?: { ipcRenderer?: IpcRendererLike } }).electron?.ipcRenderer
|
||||
}
|
||||
|
||||
async function readRequestBody(init: RequestInit): Promise<string | undefined> {
|
||||
if (typeof init.body === 'string')
|
||||
return init.body
|
||||
if (init.body == null)
|
||||
return undefined
|
||||
|
||||
return await new Response(init.body).text()
|
||||
}
|
||||
|
||||
function headersToRecord(headers: HeadersInit | undefined): Record<string, string> | undefined {
|
||||
if (!headers)
|
||||
return undefined
|
||||
|
||||
const result: Record<string, string> = {}
|
||||
new Headers(headers).forEach((value, key) => {
|
||||
result[key] = value
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
function shouldUseElectronFetchFallback(error: unknown) {
|
||||
if (typeof DOMException !== 'undefined' && error instanceof DOMException && error.name === 'AbortError')
|
||||
return false
|
||||
|
||||
return error instanceof TypeError
|
||||
}
|
||||
|
||||
function normalizeRequestMethod(init: RequestInit): string {
|
||||
return (init.method ?? 'GET').toUpperCase()
|
||||
}
|
||||
|
||||
function canReplayRequest(method: string): boolean {
|
||||
return method === 'GET' || method === 'HEAD' || method === 'OPTIONS' || method === 'TRACE'
|
||||
}
|
||||
|
||||
function createElectronFetch(ipcRenderer: IpcRendererLike, baseUrl: string): Fetch {
|
||||
const { context } = createContext(ipcRenderer)
|
||||
const invokeFetch = defineStreamInvoke(context, openAICompatibleFetch)
|
||||
|
||||
return async (input, init) => {
|
||||
const requestUrl = input.toString()
|
||||
const requestMethod = normalizeRequestMethod(init)
|
||||
|
||||
const fetchThroughElectron = async () => {
|
||||
const responseEvents = invokeFetch({
|
||||
url: requestUrl,
|
||||
baseUrl,
|
||||
method: init.method,
|
||||
headers: headersToRecord(init.headers),
|
||||
body: await readRequestBody(init),
|
||||
}, init.signal ? { signal: init.signal } : undefined)
|
||||
const reader = responseEvents.getReader()
|
||||
const head = await reader.read()
|
||||
|
||||
if (head.done || head.value.type !== 'head') {
|
||||
await reader.cancel()
|
||||
throw new Error('OpenAI-compatible fetch bridge closed before response headers.')
|
||||
}
|
||||
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
const next = await reader.read()
|
||||
if (next.done) {
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (next.value.type !== 'chunk') {
|
||||
controller.error(new Error('OpenAI-compatible fetch bridge received an unexpected response event.'))
|
||||
await reader.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
controller.enqueue(next.value.chunk)
|
||||
},
|
||||
async cancel(reason) {
|
||||
await reader.cancel(reason)
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(body, {
|
||||
status: head.value.status,
|
||||
statusText: head.value.statusText,
|
||||
headers: head.value.headers,
|
||||
})
|
||||
}
|
||||
|
||||
if (!canReplayRequest(requestMethod))
|
||||
return await fetchThroughElectron()
|
||||
|
||||
try {
|
||||
return await globalThis.fetch(input, init)
|
||||
}
|
||||
catch (error) {
|
||||
if (!shouldUseElectronFetchFallback(error))
|
||||
throw error
|
||||
|
||||
return await fetchThroughElectron()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOpenAICompatibleFetch(baseUrl: string | URL | null | undefined): Fetch | undefined {
|
||||
if (!baseUrl)
|
||||
return undefined
|
||||
|
||||
const ipcRenderer = resolveElectronIpcRenderer()
|
||||
if (!ipcRenderer)
|
||||
return undefined
|
||||
|
||||
const cacheKey = baseUrl.toString()
|
||||
let fetch = sharedFetches.get(cacheKey)
|
||||
if (!fetch) {
|
||||
fetch = createElectronFetch(ipcRenderer, cacheKey)
|
||||
sharedFetches.set(cacheKey, fetch)
|
||||
}
|
||||
return fetch
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { createOpenAI } from '@xsai-ext/providers/create'
|
||||
import { createChatProvider, createModelProvider, merge } from '@xsai-ext/providers/utils'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { resolveOpenAICompatibleFetch } from '../../openaiCompatibleFetch'
|
||||
import { ProviderValidationCheck } from '../../types'
|
||||
import { createOpenAICompatibleValidators } from '../../validators'
|
||||
import { defineProvider } from '../registry'
|
||||
|
|
@ -41,7 +42,17 @@ export const providerOpenAICompatible = defineProvider<OpenAICompatibleConfig>({
|
|||
}),
|
||||
}),
|
||||
createProvider(config) {
|
||||
return createOpenAI(config.apiKey as string, config.baseUrl)
|
||||
const fetch = resolveOpenAICompatibleFetch(config.baseUrl)
|
||||
const providerOptions = () => ({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseUrl!,
|
||||
...(fetch ? { fetch } : {}),
|
||||
})
|
||||
|
||||
return merge(
|
||||
createChatProvider(providerOptions()),
|
||||
createModelProvider(providerOptions()),
|
||||
)
|
||||
},
|
||||
|
||||
validationRequiredWhen(config) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { listModels } from '@xsai/model'
|
|||
import { message } from '@xsai/utils-chat'
|
||||
import { Mutex } from 'es-toolkit'
|
||||
|
||||
import { resolveOpenAICompatibleFetch } from '../openaiCompatibleFetch'
|
||||
import { isModelProvider, ProviderValidationCheck } from '../types'
|
||||
|
||||
interface OpenAICompatibleValidationOptions<TConfig extends { apiKey?: string, baseUrl?: string }> {
|
||||
|
|
@ -78,7 +79,12 @@ async function resolveModels<TConfig extends { apiKey?: string | null, baseUrl?:
|
|||
return providerExtra.listModels(config, provider)
|
||||
}
|
||||
if (!isModelProvider(provider)) {
|
||||
return listModels({ baseURL: config.baseUrl!, apiKey: config.apiKey! })
|
||||
const fetch = resolveOpenAICompatibleFetch(config.baseUrl)
|
||||
return listModels({
|
||||
baseURL: config.baseUrl!,
|
||||
apiKey: config.apiKey!,
|
||||
...(fetch ? { fetch } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
return listModels(provider.model())
|
||||
|
|
@ -134,9 +140,11 @@ export function createOpenAICompatibleValidators<TConfig extends { apiKey?: stri
|
|||
}
|
||||
|
||||
try {
|
||||
const fetch = resolveOpenAICompatibleFetch(config.baseUrl)
|
||||
await generateText({
|
||||
apiKey: config.apiKey,
|
||||
baseURL: config.baseUrl!,
|
||||
...(fetch ? { fetch } : {}),
|
||||
headers: additionalHeaders,
|
||||
model: normalizedModel,
|
||||
messages: message.messages(message.user('ping')),
|
||||
|
|
@ -248,14 +256,18 @@ export function createOpenAICompatibleValidators<TConfig extends { apiKey?: stri
|
|||
const timeout = setTimeout(() => controller.abort(), 10_000)
|
||||
|
||||
try {
|
||||
const response = await fetch(modelsUrl, {
|
||||
const electronFetch = resolveOpenAICompatibleFetch(config.baseUrl)
|
||||
const requestInit: RequestInit = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
|
||||
...additionalHeaders,
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
}
|
||||
const response = electronFetch
|
||||
? await electronFetch(new URL(modelsUrl), requestInit)
|
||||
: await globalThis.fetch(modelsUrl, requestInit)
|
||||
|
||||
if (response.status >= 500) {
|
||||
const errorMessage = `Server error: HTTP ${response.status}`
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { listModels } from '@xsai/model'
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { resolveOpenAICompatibleFetch } from '../libs/providers/openaiCompatibleFetch'
|
||||
import { resolveLlmTools } from './llm-tool-resolver'
|
||||
|
||||
export type { StreamEvent, StreamOptions } from '@proj-airi/core-agent'
|
||||
|
|
@ -62,9 +63,11 @@ export const useLLM = defineStore('llm', () => {
|
|||
return []
|
||||
|
||||
try {
|
||||
const fetch = resolveOpenAICompatibleFetch(apiUrl)
|
||||
return await listModels({
|
||||
baseURL: (apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`) as `${string}/`,
|
||||
apiKey,
|
||||
...(fetch ? { fetch } : {}),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { ProviderMetadata } from '../providers'
|
|||
|
||||
import { listModels } from '@xsai/model'
|
||||
|
||||
import { resolveOpenAICompatibleFetch } from '../../libs/providers/openaiCompatibleFetch'
|
||||
import { resolveProviderSourceMetadata } from '../../libs/providers/source-metadata'
|
||||
import { CHAT_COMPLETIONS_VALIDATOR_ID, isModelProvider } from '../../libs/providers/types'
|
||||
import { getValidatorsOfProvider, validateProvider } from '../../libs/providers/validators/run'
|
||||
|
|
@ -153,9 +154,11 @@ export function convertProviderDefinitionToMetadata(
|
|||
if (!baseUrl)
|
||||
return []
|
||||
|
||||
const fetch = resolveOpenAICompatibleFetch(baseUrl)
|
||||
const models = await listModels({
|
||||
baseURL: baseUrl,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(fetch ? { fetch } : {}),
|
||||
})
|
||||
return mapModelsToMetadataModels(definition.id, models as any[])
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue