This commit is contained in:
jim139129 2026-06-26 16:44:19 +00:00 committed by GitHub
commit e2a02dfd01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 291 additions and 5 deletions

View file

@ -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 })
},
})

View file

@ -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()
}
})
}

View file

@ -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'

View 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')

View 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
}

View file

@ -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) {

View file

@ -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}`

View file

@ -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) {

View file

@ -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[])
}