import { JsonConsoleLogger, LocalSecretService, LocalVariablesService, InMemoryQueueService, } from '@pikku/core/services' import { pikkuServices } from '../.pikku/pikku-types.gen.js' import { TypedSecretService } from '../.pikku/secrets/pikku-secrets.gen.js' import { TypedVariablesService } from '../.pikku/variables/pikku-variables.gen.js' import { CFWorkerSchemaService } from '@pikku/schema-cfworker' import { Kysely, CamelCasePlugin } from 'kysely' import { LibsqlWebDialect } from '@pikku/kysely-sqlite' import type { VercelAIAgentRunner } from '@pikku/ai-vercel' import type { DB } from './types/db.types.js' export const createSingletonServices = pikkuServices(async (config, existingServices) => { const variables = existingServices?.variables ?? new TypedVariablesService(new LocalVariablesService()) const secrets = existingServices?.secrets ?? new TypedSecretService(new LocalSecretService(variables)) const logger = existingServices?.logger ?? new JsonConsoleLogger() const schema = existingServices?.schema ?? new CFWorkerSchemaService(logger) // In CF Workers and containers DATABASE_URL is injected by fabric. // In local dev (pikku dev) existingServices.kysely is the node:sqlite instance // set up by the CLI — prefer it over DATABASE_URL so sandbox mode always uses // the local migrated SQLite rather than the remote Turso URL from the cascade. // In tests existingServices.kysely is provided by the test support layer. // node:sqlite / createNodeSqliteKysely is intentionally absent — it is not // available in CF Workers. See tests/support/services.ts for the dev path. let kysely: Kysely | undefined if (existingServices?.kysely) { kysely = existingServices.kysely as Kysely } else { const databaseUrl = await variables.get('DATABASE_URL') if (databaseUrl) { if (/^postgres(ql)?:\/\//.test(databaseUrl)) { const [{ PostgresJSDialect }, postgres] = await Promise.all([ import('kysely-postgres-js'), import('postgres'), ]) kysely = new Kysely({ dialect: new PostgresJSDialect({ postgres: postgres.default(databaseUrl), }), plugins: [new CamelCasePlugin()], }) } else { kysely = new Kysely({ dialect: new LibsqlWebDialect({ url: databaseUrl }), plugins: [new CamelCasePlugin()], }) } } else { throw new Error( 'kysely service not provided: set DATABASE_URL (Postgres or libsql) or pass kysely via existingServices', ) } } const litellmProxyUrl = process.env.LITELLM_PROXY_URL ?? null const litellmApiKey = process.env.LITELLM_API_KEY ?? null let aiAgentRunner: VercelAIAgentRunner | undefined if (litellmProxyUrl && litellmApiKey) { // The AI SDKs (~3MB) are stubbed out of non-agent units at bundle time — // only units with the `ai-model` capability keep them. So import them // dynamically and guard on the module resolving to a real export; in a // stubbed unit the import yields `{}` and the runner is simply not built. const aiVercel = await import('@pikku/ai-vercel') const aiSdk = await import('@ai-sdk/openai-compatible') if (aiVercel.VercelAIAgentRunner && aiSdk.createOpenAICompatible) { const litellmProvider = aiSdk.createOpenAICompatible({ name: 'litellm', baseURL: litellmProxyUrl, apiKey: litellmApiKey, }) aiAgentRunner = new aiVercel.VercelAIAgentRunner({ openai: (modelId: string) => litellmProvider.chatModel(modelId), anthropic: (modelId: string) => litellmProvider.chatModel(modelId), google: (modelId: string) => litellmProvider.chatModel(modelId), deepseek: (modelId: string) => litellmProvider.chatModel(modelId), }) } } const queueService = existingServices?.queueService ?? new InMemoryQueueService() return { ...(existingServices ?? {}), config, variables, secrets, logger, schema, kysely, queueService, ...(aiAgentRunner ? { aiAgentRunner } : {}), } })