Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/stage-tamagotchi/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -259,6 +260,7 @@ app.whenReady().then(async () => {
context,
artistryConfig: deps.artistryConfig,
})
setupOpenAICompatibleFetchBridge({ context })
Comment thread
jim139129 marked this conversation as resolved.
Outdated
},
})

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import type { createContext as createMainEventaContext } from '@moeru/eventa/adapters/electron/main'

import { defineStreamInvokeHandler } from '@moeru/eventa'
import { openAICompatibleFetch } from '@proj-airi/stage-shared'

export function setupOpenAICompatibleFetchBridge(params: {
context: ReturnType<typeof createMainEventaContext>['context']
}) {
defineStreamInvokeHandler(params.context, openAICompatibleFetch, async function* (payload, options) {
const url = new URL(payload.url)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('OpenAI-compatible fetch only supports http and https URLs.')
}
Comment thread
jim139129 marked this conversation as resolved.
Outdated

const response = await fetch(url, {
method: payload.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()
}
})
}
1 change: 1 addition & 0 deletions packages/stage-shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
24 changes: 24 additions & 0 deletions packages/stage-shared/src/openai-compatible.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { defineInvokeEventa } from '@moeru/eventa'

export interface OpenAICompatibleFetchRequest {
url: 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')
126 changes: 126 additions & 0 deletions packages/stage-ui/src/libs/providers/openaiCompatibleFetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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]

let sharedFetch: Fetch | undefined

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): 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,
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()
Comment thread
jim139129 marked this conversation as resolved.

try {
return await globalThis.fetch(input, init)
}
catch (error) {
if (!shouldUseElectronFetchFallback(error))
throw error

return await fetchThroughElectron()
}
}
}

export function resolveOpenAICompatibleFetch(): Fetch | undefined {
const ipcRenderer = resolveElectronIpcRenderer()
if (!ipcRenderer)
return undefined

sharedFetch ??= createElectronFetch(ipcRenderer)
return sharedFetch
}
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -41,7 +42,17 @@ export const providerOpenAICompatible = defineProvider<OpenAICompatibleConfig>({
}),
}),
createProvider(config) {
return createOpenAI(config.apiKey as string, config.baseUrl)
const fetch = resolveOpenAICompatibleFetch()
const providerOptions = () => ({
apiKey: config.apiKey,
baseURL: config.baseUrl!,
...(fetch ? { fetch } : {}),
})

return merge(
createChatProvider(providerOptions()),
createModelProvider(providerOptions()),
)
},

validationRequiredWhen(config) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> {
Expand Down Expand Up @@ -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()
return listModels({
baseURL: config.baseUrl!,
apiKey: config.apiKey!,
...(fetch ? { fetch } : {}),
})
}

return listModels(provider.model())
Expand Down Expand Up @@ -134,9 +140,11 @@ export function createOpenAICompatibleValidators<TConfig extends { apiKey?: stri
}

try {
const fetch = resolveOpenAICompatibleFetch()
await generateText({
apiKey: config.apiKey,
baseURL: config.baseUrl!,
...(fetch ? { fetch } : {}),
Comment thread
jim139129 marked this conversation as resolved.
Outdated
headers: additionalHeaders,
model: normalizedModel,
messages: message.messages(message.user('ping')),
Expand Down Expand Up @@ -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()
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)
Comment thread
jim139129 marked this conversation as resolved.

if (response.status >= 500) {
const errorMessage = `Server error: HTTP ${response.status}`
Expand Down
3 changes: 3 additions & 0 deletions packages/stage-ui/src/stores/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { uniqBy } from 'es-toolkit'
import { defineStore } from 'pinia'
import { ref } from 'vue'

import { resolveOpenAICompatibleFetch } from '../libs/providers/openaiCompatibleFetch'
import { createSparkCommandTool, debug, mcp } from '../tools'
import { useLlmToolsStore } from './llm-tools'
import { useModsServerChannelStore } from './mods/api/channel-server'
Expand Down Expand Up @@ -107,9 +108,11 @@ export const useLLM = defineStore('llm', () => {
return []

try {
const fetch = resolveOpenAICompatibleFetch()
return await listModels({
baseURL: (apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`) as `${string}/`,
apiKey,
...(fetch ? { fetch } : {}),
})
}
catch (err) {
Expand Down
3 changes: 3 additions & 0 deletions packages/stage-ui/src/stores/providers/converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -143,6 +144,7 @@ export function convertProviderDefinitionToMetadata(
: async (config) => {
const provider = await definition.createProvider(config as any)
try {
const fetch = resolveOpenAICompatibleFetch()
if (isModelProvider(provider)) {
const models = await listModels(provider.model())
return mapModelsToMetadataModels(definition.id, models as any[])
Expand All @@ -156,6 +158,7 @@ export function convertProviderDefinitionToMetadata(
const models = await listModels({
baseURL: baseUrl,
...(apiKey ? { apiKey } : {}),
...(fetch ? { fetch } : {}),
})
return mapModelsToMetadataModels(definition.id, models as any[])
}
Expand Down
Loading