1/**
2 * Provider discovery service for AI codegen.
3 *
4 * @since 4.0.0
5 */
6import * as Context from "effect/Context"
7import * as Data from "effect/Data"
8import * as Effect from "effect/Effect"
9import * as FileSystem from "effect/FileSystem"
10import * as Layer from "effect/Layer"
11import * as Path from "effect/Path"
12import * as Schema from "effect/Schema"
13import * as Yaml from "yaml"
14import { CodegenConfig, type SpecSource, SpecSource as SpecSourceUtils } from "./Config.ts"
15import * as Glob from "./Glob.ts"
16
17/**
18 * A discovered AI provider with resolved paths.
19 *
20 * **Example** (Inspecting a discovered provider)
21 *
22 * ```ts import.meta.vitest
23 * import * as Config from "@effect/ai-codegen/Config"
24 * import type * as Discovery from "@effect/ai-codegen/Discovery"
25 *
26 * const provider: Discovery.DiscoveredProvider = {
27 * name: "openai",
28 * packagePath: "packages/ai/openai",
29 * config: new Config.CodegenConfig({
30 * spec: "https://example.com/openapi.json",
31 * output: "Generated.ts"
32 * }),
33 * specSource: Config.SpecSource.Url("https://example.com/openapi.json"),
34 * outputPath: "packages/ai/openai/src/Generated.ts"
35 * }
36 *
37 * provider.name // => "openai"
38 * provider.specSource._tag // => "Url"
39 * ```
40 *
41 * @category models
42 * @since 4.0.0
43 */
44export interface DiscoveredProvider {
45 readonly name: string
46 readonly packagePath: string
47 readonly config: CodegenConfig
48 readonly specSource: SpecSource
49 readonly outputPath: string
50}
51
52/**
53 * Service for discovering AI provider configurations.
54 *
55 * @category services
56 * @since 4.0.0
57 */
58export interface ProviderDiscovery {
59 readonly discover: () => Effect.Effect<
60 Array<DiscoveredProvider>,
61 DiscoveryError | Glob.GlobError
62 >
63 readonly discoverOne: (
64 name: string
65 ) => Effect.Effect<
66 DiscoveredProvider,
67 DiscoveryError | ProviderNotFoundError | Glob.GlobError
68 >
69}
70
71/**
72 * Service tag for discovering AI provider codegen configurations.
73 *
74 * @category services
75 * @since 4.0.0
76 */
77export const ProviderDiscovery: Context.Service<ProviderDiscovery, ProviderDiscovery> = Context.Service(
78 "@effect/ai-codegen/ProviderDiscovery"
79)
80
81/**
82 * Error during provider discovery.
83 *
84 * **Example** (Creating a discovery error)
85 *
86 * ```ts import.meta.vitest
87 * import * as Discovery from "@effect/ai-codegen/Discovery"
88 *
89 * const error = new Discovery.DiscoveryError({
90 * message: "Failed to parse config",
91 * cause: new Error("Invalid JSON")
92 * })
93 *
94 * error._tag // => "DiscoveryError"
95 * error.message // => "Failed to parse config"
96 * ```
97 *
98 * @category errors
99 * @since 4.0.0
100 */
101export class DiscoveryError extends Data.TaggedError("DiscoveryError")<{
102 readonly message: string
103 readonly cause?: unknown
104}> {}
105
106/**
107 * Error when a specific provider is not found.
108 *
109 * **Example** (Creating a provider not found error)
110 *
111 * ```ts import.meta.vitest
112 * import * as Discovery from "@effect/ai-codegen/Discovery"
113 *
114 * const error = new Discovery.ProviderNotFoundError({
115 * provider: "openai",
116 * available: ["anthropic", "google"]
117 * })
118 *
119 * error.provider // => "openai"
120 * error.available // => ["anthropic", "google"]
121 * ```
122 *
123 * @category errors
124 * @since 4.0.0
125 */
126export class ProviderNotFoundError extends Data.TaggedError("ProviderNotFoundError")<{
127 readonly provider: string
128 readonly available: ReadonlyArray<string>
129}> {}
130
131/**
132 * Layer providing the ProviderDiscovery service.
133 *
134 * @category layers
135 * @since 4.0.0
136 */
137export const layer: Layer.Layer<
138 ProviderDiscovery,
139 never,
140 Glob.Glob | FileSystem.FileSystem | Path.Path
141> = Effect.gen(function*() {
142 const glob = yield* Glob.Glob
143 const fs = yield* FileSystem.FileSystem
144 const pathService = yield* Path.Path
145
146 const parseConfig = Effect.fn("parseConfig")(function*(configPath: string) {
147 const packagePath = pathService.dirname(configPath)
148 const name = pathService.basename(packagePath)
149 const isYaml = configPath.endsWith(".yaml") || configPath.endsWith(".yml")
150
151 const content = yield* fs.readFileString(configPath).pipe(
152 Effect.mapError((cause) =>
153 new DiscoveryError({
154 message: `Failed to read config at ${configPath}`,
155 cause
156 })
157 )
158 )
159
160 const parsed = yield* Effect.try({
161 try: () => isYaml ? Yaml.parse(content) : JSON.parse(content),
162 catch: (cause) =>
163 new DiscoveryError({
164 message: `Failed to parse ${isYaml ? "YAML" : "JSON"} at ${configPath}`,
165 cause
166 })
167 })
168
169 const config = yield* Schema.decodeUnknownEffect(CodegenConfig)(parsed).pipe(
170 Effect.mapError((cause) =>
171 new DiscoveryError({
172 message: `Invalid config schema at ${configPath}`,
173 cause
174 })
175 )
176 )
177
178 const provider: DiscoveredProvider = {
179 name,
180 packagePath,
181 config,
182 specSource: SpecSourceUtils.fromConfig(config.spec, packagePath, pathService),
183 outputPath: pathService.join(packagePath, config.output)
184 }
185
186 return provider
187 })
188
189 const discover = Effect.fn("discover")(function*() {
190 const configFiles = yield* glob.glob("packages/ai/*/codegen.{json,yaml,yml}", {
191 cwd: process.cwd(),
192 absolute: true
193 })
194
195 return yield* Effect.forEach(configFiles, parseConfig)
196 })
197
198 const discoverOne = Effect.fn("discoverOne")(function*(providerName: string) {
199 const providers = yield* discover()
200 const found = providers.find((p) => p.name === providerName)
201
202 if (!found) {
203 return yield* new ProviderNotFoundError({
204 provider: providerName,
205 available: providers.map((p) => p.name)
206 })
207 }
208
209 return found
210 })
211
212 return { discover, discoverOne }
213}).pipe(Layer.effect(ProviderDiscovery))