Something went wrong. Try again.
ATProto-based AI Assistant Chat interface.
Something went wrong. Try again.
5.3 kB · 118 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119import { randomBytes } from 'node:crypto'import { createOpenAICompatible } from '@ai-sdk/openai-compatible'import { definePlugin } from '@scn-chat/plugin-api'import { extractReasoningMiddleware, wrapLanguageModel } from 'ai'import { z } from 'zod'
export const optionsSchema = z .object({ id: z .string() .regex(/^[a-z0-9-]+$/) .meta({ title: 'Provider ID', description: "Used in model references, such as 'scn' or 'llama'. Fixed once added.", }), name: z.string().min(1).meta({ title: 'Name' }), baseURL: z.url().optional().meta({ title: 'Base URL', description: 'The admin endpoint.' }), apiKey: z.string().min(1).optional().meta({ title: 'API key', secret: true }), userKeys: z.boolean().optional().meta({ title: 'Allow users to add their own keys' }), userEndpoints: z.boolean().optional().meta({ title: 'Allow users to set their own endpoints' }), allowPrivateNetworks: z.boolean().optional().meta({ title: 'User endpoints can reach private networks', description: 'For self-hosted local models.', }), sessionHeader: z .string() .regex(/^[A-Za-z0-9-]+$/) .optional() .meta({ title: 'Session header', description: "A header that carries an ID for each conversation, for endpoints that require one, such as 'x-opencode-session' for OpenCode Go.", }), userAgent: z.string().min(1).optional().meta({ title: 'User agent', description: "Leads the user agent, for endpoints that ask clients to name themselves. Chat calls add the SDK's name after it.", }), startWithReasoning: z.boolean().optional().meta({ title: 'Replies start inside the reasoning', description: 'For models whose template opens the <think> tag in the prompt, such as DeepSeek R1 served without a reasoning parser, so a reply has only the closing tag. Leave it off if any model on this endpoint answers without reasoning, since all of its reply would show as reasoning.', }), }) .strict()
export type OpenAICompatibleOptions = z.infer<typeof optionsSchema>
/** Each endpoint is its own instance, with its own provider ID. */export const multipleInstances = true
/** The headers the admin set for this endpoint, with the conversation's session ID when there is one. */function headersFor(options: OpenAICompatibleOptions, session?: string): Record<string, string> { return { ...(options.userAgent ? { 'user-agent': options.userAgent } : {}), ...(options.sessionHeader && session ? { [options.sessionHeader]: session } : {}), }}
const modelList = z.object({ data: z.array(z.object({ id: z.string().min(1) })) })
/** Any OpenAI-compatible endpoint: OpenRouter, scn, co/core, llama.cpp, vLLM, Ollama, and others. */export default function openaiCompatible(options: OpenAICompatibleOptions) { return definePlugin({ id: `openai-compatible-${options.id}`, name: options.name, apiVersion: 1, setup(ctx) { ctx.providers.register({ id: options.id, name: options.name, // Local servers such as llama.cpp and Ollama don't need a key. hasAdminKey: Boolean(options.baseURL), userKeys: options.userKeys ?? true, userEndpoints: options.userEndpoints ?? false, allowPrivateNetworks: options.allowPrivateNetworks ?? false, replay: 'drop', createModel: ({ modelId, apiKey, baseURL, fetch, session }) => { const url = baseURL ?? options.baseURL if (!url) throw new Error(`Provider "${options.id}" has no base URL`) const model = createOpenAICompatible({ name: options.id, baseURL: url, apiKey: apiKey ?? options.apiKey, headers: headersFor(options, session), includeUsage: true, fetch, })(modelId) // Servers that leave reasoning in the text, such as llama.cpp with --reasoning-format none, wrap it in think tags. return wrapLanguageModel({ model, middleware: extractReasoningMiddleware({ tagName: 'think', startWithReasoning: options.startWithReasoning ?? false, }), }) }, async listModels({ apiKey, baseURL, fetch = globalThis.fetch }) { const url = baseURL ?? options.baseURL if (!url) throw new Error(`Provider "${options.id}" has no base URL`) const key = apiKey ?? options.apiKey const response = await fetch(`${url.replace(/\/$/, '')}/models`, { // The list belongs to no conversation, so it carries a session of its own, shaped like one. headers: { ...headersFor(options, randomBytes(16).toString('hex')), ...(key ? { authorization: `Bearer ${key}` } : {}), }, redirect: 'error', }) if (!response.ok) throw new Error(`Listing models at ${url} returned ${response.status}`) const body = modelList.safeParse(await response.json()) if (!body.success) throw new Error(`${url}/models did not return a valid model list`) return body.data.data.map((model) => ({ id: model.id, name: model.id })) }, }) }, })}