saeeol 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "name": "saeeol",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,307 @@
1
+ import fs from "fs/promises"
2
+ import path from "path"
3
+ import { cmd } from "./cmd"
4
+ import * as prompts from "@clack/prompts"
5
+ import { UI } from "../ui"
6
+ import { Global } from "@saeeol/core/global"
7
+ import { Instance } from "../../project/instance"
8
+ import { AppRuntime } from "../../effect/app-runtime"
9
+ import { Config } from "@/config/config"
10
+ import { Effect } from "effect"
11
+ import { put } from "./providers-auth"
12
+ import { ModelsDev } from "@/provider/models"
13
+ import { map, pipe, sortBy, values } from "remeda"
14
+
15
+ const { TEXT_HIGHLIGHT: H, TEXT_NORMAL: N, TEXT_DIM: D, TEXT_SUCCESS: S, TEXT_NORMAL_BOLD: B } = UI.Style
16
+
17
+ const getModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.get()))
18
+ const refreshModels = () => AppRuntime.runPromise(ModelsDev.Service.use((s) => s.refresh(true)))
19
+
20
+ type ProviderInfo = { id: string; name: string; env: string[]; models: Record<string, unknown> }
21
+
22
+ export const InitCommand = cmd({
23
+ command: "init",
24
+ describe: "최초 설정 — provider, API key, 모델 선택",
25
+ builder: (y) =>
26
+ y
27
+ .option("provider", {
28
+ type: "string",
29
+ alias: "p",
30
+ describe: "provider id (예: anthropic, openai, google)",
31
+ })
32
+ .option("api-key", {
33
+ type: "string",
34
+ alias: "k",
35
+ describe: "API key",
36
+ })
37
+ .option("model", {
38
+ type: "string",
39
+ alias: "m",
40
+ describe: "기본 모델 (예: anthropic/claude-sonnet-4)",
41
+ })
42
+ .option("base-url", {
43
+ type: "string",
44
+ describe: "커스텀 API base URL (OpenAI 호환 엔드포인트용)",
45
+ }),
46
+ handler: async (args) => {
47
+ await Instance.provide({
48
+ directory: process.cwd(),
49
+ async fn() {
50
+ UI.empty()
51
+ UI.println(logo())
52
+ UI.empty()
53
+ prompts.intro(`${B}SAEEOL 초기 설정${N}`)
54
+ UI.empty()
55
+
56
+ const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.get()))
57
+
58
+ // 1. Provider 목록 동적 로드
59
+ const s = prompts.spinner()
60
+ s.start("provider 목록 로드 중...")
61
+ await refreshModels().catch(() => {})
62
+ const database = await getModels()
63
+ s.stop(`${Object.keys(database).length}개 provider 로드 완료`)
64
+
65
+ // 2. Provider 선택
66
+ const disabled = new Set(config.disabled_providers ?? [])
67
+ const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined
68
+ const providers = Object.entries(database)
69
+ .filter(([key]) => (enabled ? enabled.has(key) : true) && !disabled.has(key))
70
+ .map(([id, info]) => ({ id, name: info.name || id, env: info.env, models: info.models || {} } as ProviderInfo))
71
+
72
+ const priority: Record<string, number> = {
73
+ anthropic: 0, openai: 1, google: 2, "github-copilot": 3, openrouter: 4, groq: 5,
74
+ "amazon-bedrock": 6, azure: 7, mistral: 8, deepinfra: 9, xai: 10, cerebras: 11,
75
+ }
76
+
77
+ let providerId: string
78
+ if (args.provider) {
79
+ providerId = args.provider
80
+ const found = providers.find((p) => p.id === providerId)
81
+ if (!found) {
82
+ // 커스텀 provider — base-url 필요
83
+ if (!args["base-url"]) {
84
+ prompts.log.warn(`"${providerId}"은(는) 알려진 provider가 아닙니다. --base-url이 필요합니다.`)
85
+ return
86
+ }
87
+ }
88
+ } else {
89
+ const sorted = pipe(
90
+ providers,
91
+ sortBy((x) => priority[x.id] ?? 99, (x) => x.name),
92
+ )
93
+ const selected = await prompts.select({
94
+ message: "사용할 AI Provider를 선택하세요",
95
+ maxItems: 12,
96
+ options: [
97
+ ...sorted.map((p) => ({ label: p.name, value: p.id })),
98
+ { label: "직접 입력 (OpenAI 호환)", value: "__custom__" },
99
+ ],
100
+ })
101
+ if (prompts.isCancel(selected)) throw new UI.CancelledError()
102
+ providerId = selected as string
103
+ }
104
+
105
+ // 3. 커스텀 엔드포인트
106
+ if (providerId === "__custom__") {
107
+ await handleCustom(args, database)
108
+ return
109
+ }
110
+
111
+ // 4. API Key 입력
112
+ const providerInfo = providers.find((p) => p.id === providerId)
113
+ const envVar = providerInfo?.env?.[0]
114
+ const existingKey = envVar ? process.env[envVar] : undefined
115
+
116
+ if (existingKey) {
117
+ prompts.log.info(`${D}${envVar} 환경변수가 이미 설정되어 있습니다.${N}`)
118
+ } else if (providerId === "github-copilot") {
119
+ prompts.log.info("GitHub Copilot은 OAuth 로그인이 필요합니다.")
120
+ prompts.log.info(` ${H}saeeol auth login -p github-copilot${N} 으로 로그인하세요.`)
121
+ } else {
122
+ const hint = providerHint(providerId)
123
+ if (hint) prompts.log.info(`${D}API key 발급: ${hint}${N}`)
124
+
125
+ const key = args["api-key"] ?? (await prompts.password({
126
+ message: `${providerInfo?.name ?? providerId} API Key`,
127
+ validate: (x) => (x && x.length > 0 ? undefined : "필수 입력입니다"),
128
+ }))
129
+ if (prompts.isCancel(key)) throw new UI.CancelledError()
130
+ await put(providerId, { type: "api", key })
131
+ prompts.log.success("API key 저장 완료")
132
+ }
133
+
134
+ // 5. 모델 목록 동적 조회
135
+ const model = args.model ?? (await pickModel(providerId, database))
136
+ if (!model) return
137
+
138
+ // 6. 설정 저장
139
+ await saveConfigFile({ $schema: "https://app.saeeol.ai/config.json", model })
140
+
141
+ UI.empty()
142
+ prompts.outro(`${S}설정 완료!${N}`)
143
+ UI.empty()
144
+ UI.println(` ${D}시작하기:${N}`)
145
+ UI.println(` ${H}saeeol${N} ${D}# 대화형 세션 시작${N}`)
146
+ UI.println(` ${H}saeeol run${N} ${D}# 프롬프트 바로 실행${N}`)
147
+ UI.empty()
148
+ },
149
+ })
150
+ },
151
+ })
152
+
153
+ async function pickModel(
154
+ providerId: string,
155
+ database: Record<string, any>,
156
+ ): Promise<string | undefined> {
157
+ const provider = database[providerId]
158
+ if (!provider?.models) {
159
+ prompts.log.warn("해당 provider의 모델 목록을 가져올 수 없습니다.")
160
+ return `${providerId}/default`
161
+ }
162
+
163
+ const models = Object.entries(provider.models as Record<string, any>)
164
+ .filter(([, m]) => m.status !== "deprecated" && m.status !== "alpha")
165
+ .map(([id, m]) => ({
166
+ id: `${providerId}/${id}`,
167
+ name: m.name || id,
168
+ context: m.limit?.context,
169
+ }))
170
+
171
+ if (models.length === 0) {
172
+ prompts.log.warn("사용 가능한 모델이 없습니다.")
173
+ return `${providerId}/default`
174
+ }
175
+
176
+ // 컨텍스트 길이 기준 내림차순 정렬 (큰 게 보통 더 강력)
177
+ models.sort((a, b) => (b.context ?? 0) - (a.context ?? 0))
178
+
179
+ const selected = await prompts.select({
180
+ message: "기본 모델을 선택하세요",
181
+ maxItems: 10,
182
+ options: models.map((m) => ({
183
+ label: m.name,
184
+ value: m.id,
185
+ hint: m.context ? `${(m.context / 1000).toFixed(0)}K ctx` : undefined,
186
+ })),
187
+ })
188
+ if (prompts.isCancel(selected)) throw new UI.CancelledError()
189
+ return selected as string
190
+ }
191
+
192
+ async function handleCustom(args: Record<string, unknown>, database: Record<string, any>) {
193
+ const baseUrl = (args["base-url"] as string) ?? (await prompts.text({
194
+ message: "API Base URL",
195
+ placeholder: "https://api.example.com/v1",
196
+ validate: (x) => (x && x.startsWith("http") ? undefined : "http(s)://로 시작해야 합니다"),
197
+ }))
198
+ if (prompts.isCancel(baseUrl)) throw new UI.CancelledError()
199
+
200
+ const key = (args["api-key"] as string) ?? (await prompts.password({
201
+ message: "API Key",
202
+ validate: (x) => (x && x.length > 0 ? undefined : "필수 입력입니다"),
203
+ }))
204
+ if (prompts.isCancel(key)) throw new UI.CancelledError()
205
+
206
+ // /v1/models 호출로 동적 모델 조회
207
+ let modelId: string | undefined
208
+ try {
209
+ const resp = await fetch(`${baseUrl.replace(/\/+$/, "")}/models`, {
210
+ headers: { Authorization: `Bearer ${key}` },
211
+ signal: AbortSignal.timeout(10000),
212
+ })
213
+ if (resp.ok) {
214
+ const body = (await resp.json()) as { data?: Array<{ id: string }> }
215
+ const remoteModels = body.data ?? []
216
+ if (remoteModels.length > 0) {
217
+ const selected = await prompts.select({
218
+ message: `${remoteModels.length}개 모델 발견. 기본 모델 선택`,
219
+ maxItems: 10,
220
+ options: remoteModels.slice(0, 50).map((m) => ({ label: m.id, value: m.id })),
221
+ })
222
+ if (!prompts.isCancel(selected)) modelId = selected as string
223
+ }
224
+ }
225
+ } catch {
226
+ // /models 엔드포인트 없으면 수동 입력
227
+ }
228
+
229
+ if (!modelId) {
230
+ modelId = (args.model as string) ?? (await prompts.text({
231
+ message: "모델 ID",
232
+ placeholder: "gpt-4o",
233
+ validate: (x) => (x && x.length > 0 ? undefined : "필수 입력입니다"),
234
+ })) as string
235
+ if (prompts.isCancel(modelId)) throw new UI.CancelledError()
236
+ }
237
+
238
+ const pid = `custom-${Date.now().toString(36)}`
239
+ await put(pid, { type: "api", key })
240
+
241
+ await saveConfigFile({
242
+ $schema: "https://app.saeeol.ai/config.json",
243
+ model: `${pid}/${modelId}`,
244
+ provider: {
245
+ [pid]: {
246
+ name: "Custom",
247
+ npm: "@ai-sdk/openai-compatible",
248
+ api: baseUrl,
249
+ },
250
+ },
251
+ })
252
+
253
+ UI.empty()
254
+ prompts.outro(`${S}설정 완료!${N}`)
255
+ }
256
+
257
+ function providerHint(id: string): string | undefined {
258
+ const hints: Record<string, string> = {
259
+ anthropic: "https://console.anthropic.com/settings/keys",
260
+ openai: "https://platform.openai.com/api-keys",
261
+ google: "https://aistudio.google.com/apikey",
262
+ openrouter: "https://openrouter.ai/keys",
263
+ groq: "https://console.groq.com/keys",
264
+ "amazon-bedrock": "AWS 자격 증명 필요",
265
+ azure: "Azure OpenAI 리소스 필요",
266
+ mistral: "https://console.mistral.ai/api-keys",
267
+ deepinfra: "https://deepinfra.com/dash/api_keys",
268
+ xai: "https://console.x.ai",
269
+ cerebras: "https://cloud.cerebras.ai",
270
+ }
271
+ return hints[id]
272
+ }
273
+
274
+ function logo(): string {
275
+ const lines = [
276
+ " ██████╗ █████╗ ███████╗███████╗██╗ ██╗",
277
+ " ██╔══██╗██╔══██╗██╔════╝██╔════╝██║ ██╔╝",
278
+ " ██████╔╝███████║███████╗███████╗█████╔╝ ",
279
+ " ██╔═══╝ ██╔══██║╚════██║╚════██║██╔═██╗ ",
280
+ " ██║ ██║ ██║███████║███████║██║ ██╗",
281
+ " ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝",
282
+ ]
283
+ return lines.map((l) => `${H}${l}${N}`).join("\n")
284
+ }
285
+
286
+ async function saveConfigFile(config: Record<string, unknown>) {
287
+ const configPath = path.join(Global.Path.config, "saeeol.json")
288
+ const existing = await fs.readFile(configPath, "utf-8").catch(() => null)
289
+
290
+ if (existing) {
291
+ try {
292
+ const parsed = JSON.parse(existing)
293
+ const merged = { ...parsed, ...config }
294
+ if (parsed.provider && config.provider) {
295
+ merged.provider = { ...parsed.provider, ...(config.provider as Record<string, unknown>) }
296
+ }
297
+ await fs.writeFile(configPath, JSON.stringify(merged, null, 2) + "\n", "utf-8")
298
+ } catch {
299
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
300
+ }
301
+ } else {
302
+ await fs.mkdir(Global.Path.config, { recursive: true })
303
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
304
+ }
305
+
306
+ prompts.log.success(`설정 파일: ${D}${configPath}${N}`)
307
+ }
@@ -1,64 +1,8 @@
1
- import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
2
- import fuzzysort from "fuzzysort"
3
- import { Effect, Layer, Context, Schema } from "effect"
4
- import * as Log from "@saeeol/core/util/log"
5
- import { iife } from "@/util/iife"
6
- import { isRecord } from "@/util/record"
1
+ import { Effect, Schema } from "effect"
7
2
  import { namedSchemaError } from "@/util/named-schema-error"
8
- import { Config } from "@/config/config"
9
- import { Plugin } from "../plugin"
10
- import { Auth } from "../auth"
11
- import { Env } from "../env"
12
- import { makeRuntime } from "@/effect/run-service"
13
- import { EffectBridge } from "@/effect/bridge"
14
- import { InstanceState } from "@/effect/instance-state"
15
- import { AppFileSystem } from "@saeeol/core/filesystem"
16
- import { Global } from "@saeeol/core/global"
17
- import { Flag } from "@saeeol/core/flag/flag"
18
- import path from "path"
19
- import * as ModelsDev from "./models"
20
3
  import { ModelID, ProviderID } from "./schema"
21
- import * as ProviderTransform from "./transform"
22
- import { custom } from "./provider-custom-loaders"
23
- import { customCloud } from "./provider-custom-cloud"
24
- import { customGitlab } from "./provider-custom-gitlab"
25
4
  import { Model, Info, ListResult, ConfigProvidersResult, fromModelsDevProvider, sortModels, defaultModelIDs } from "./provider-schemas"
26
- import { resolveSDK } from "./provider-resolve"
27
5
  import type { State, BundledSDK } from "./provider-types"
28
- import { saeeolCustomLoaders, patchCustomLoaderResult, saeeolSmallModelPriority } from "@/saeeol/provider/provider"
29
-
30
- const log = Log.create({ service: "provider" })
31
-
32
- type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
33
- type CustomVarsLoader = (options: Record<string, any>) => Record<string, string>
34
- type CustomDiscoverModels = () => Promise<Record<string, Model>>
35
-
36
- const BUNDLED_PROVIDERS: Record<string, () => Promise<(opts: any) => BundledSDK>> = {
37
- "@ai-sdk/amazon-bedrock": () => import("@ai-sdk/amazon-bedrock").then((m) => m.createAmazonBedrock),
38
- "@ai-sdk/anthropic": () => import("@ai-sdk/anthropic").then((m) => m.createAnthropic),
39
- "@ai-sdk/azure": () => import("@ai-sdk/azure").then((m) => m.createAzure),
40
- "@ai-sdk/google": () => import("@ai-sdk/google").then((m) => m.createGoogleGenerativeAI),
41
- "@ai-sdk/google-vertex": () => import("@ai-sdk/google-vertex").then((m) => m.createVertex),
42
- "@ai-sdk/google-vertex/anthropic": () => import("@ai-sdk/google-vertex/anthropic").then((m) => m.createVertexAnthropic),
43
- "@ai-sdk/openai": () => import("@ai-sdk/openai").then((m) => m.createOpenAI),
44
- "@ai-sdk/openai-compatible": () => import("@ai-sdk/openai-compatible").then((m) => m.createOpenAICompatible),
45
- "@openrouter/ai-sdk-provider": () => import("@openrouter/ai-sdk-provider").then((m) => m.createOpenRouter),
46
- "@ai-sdk/xai": () => import("@ai-sdk/xai").then((m) => m.createXai),
47
- "@ai-sdk/mistral": () => import("@ai-sdk/mistral").then((m) => m.createMistral),
48
- "@ai-sdk/groq": () => import("@ai-sdk/groq").then((m) => m.createGroq),
49
- "@ai-sdk/deepinfra": () => import("@ai-sdk/deepinfra").then((m) => m.createDeepInfra),
50
- "@ai-sdk/cerebras": () => import("@ai-sdk/cerebras").then((m) => m.createCerebras),
51
- "@ai-sdk/cohere": () => import("@ai-sdk/cohere").then((m) => m.createCohere),
52
- "@ai-sdk/gateway": () => import("@ai-sdk/gateway").then((m) => m.createGateway),
53
- "@ai-sdk/togetherai": () => import("@ai-sdk/togetherai").then((m) => m.createTogetherAI),
54
- "@ai-sdk/perplexity": () => import("@ai-sdk/perplexity").then((m) => m.createPerplexity),
55
- "@ai-sdk/vercel": () => import("@ai-sdk/vercel").then((m) => m.createVercel),
56
- "@ai-sdk/alibaba": () => import("@ai-sdk/alibaba").then((m) => m.createAlibaba),
57
- "gitlab-ai-provider": () => import("gitlab-ai-provider").then((m) => m.createGitLab),
58
- "@ai-sdk/github-copilot": () => import("./sdk/copilot/copilot-provider").then((m) => m.createOpenaiCompatible),
59
- "venice-ai-sdk-provider": () => import("venice-ai-sdk-provider").then((m) => m.createVenice),
60
- ...require("@/saeeol/provider/provider").SAEEOL_BUNDLED_PROVIDERS,
61
- }
62
6
 
63
7
  export interface Interface {
64
8
  readonly list: () => Effect.Effect<Record<ProviderID, Info>>
@@ -22,6 +22,7 @@ import { SessionCommand } from "../cli/cmd/session"
22
22
  import { RemoteCommand } from "../cli/cmd/remote"
23
23
  import { DbCommand } from "../cli/cmd/db"
24
24
  import { ConfigCommand as ConfigCLICommand } from "../cli/cmd/config"
25
+ import { InitCommand } from "../cli/cmd/init"
25
26
  import { PluginCommand } from "../cli/cmd/plug"
26
27
  import { DevSetupCommand, DevAliasCommand } from "./cli/dev-setup"
27
28
  import { RollCallCommand } from "./cli/cmd/roll-call"
@@ -65,6 +66,7 @@ export const commands = [
65
66
  RemoteCommand,
66
67
  DbCommand,
67
68
  ConfigCLICommand,
69
+ InitCommand,
68
70
  ...dev,
69
71
  PluginCommand,
70
72
  HelpCommand,
@@ -1,171 +0,0 @@
1
- /** 클라우드 프로바이더 커스텀 로더 — provider.ts에서 분리 */
2
-
3
- import { Effect } from "effect"
4
- import { iife } from "@/util/iife"
5
- import { InstallationVersion } from "@saeeol/core/installation/version"
6
- import { useLanguageModel, type CustomLoader, type CustomDep } from "./provider-custom-loaders"
7
- import type { Info } from "./provider-schemas"
8
-
9
- export function customCloud(dep: CustomDep): Record<string, CustomLoader> {
10
- return {
11
- azure: Effect.fnUntraced(function* (provider: Info) {
12
- const env = yield* dep.env()
13
- const auth = yield* dep.auth(provider.id)
14
- const endpoint = iife(() => [provider.options?.baseURL, auth?.type === "api" ? auth.metadata?.baseURL : undefined, env["AZURE_OPENAI_ENDPOINT"]].find((url) => typeof url === "string" && url.trim() !== ""))
15
- const resource = endpoint ? undefined : iife(() => [provider.options?.resourceName, auth?.type === "api" ? auth.metadata?.resourceName : undefined, env["AZURE_RESOURCE_NAME"], env["AZURE_OPENAI_RESOURCE_NAME"]].find((name) => typeof name === "string" && name.trim() !== ""))
16
- if (!resource && !endpoint) {
17
- return { autoload: false, async getModel() { throw new Error("Azure resource name or endpoint is missing. Set AZURE_RESOURCE_NAME, AZURE_OPENAI_RESOURCE_NAME, AZURE_OPENAI_ENDPOINT, or reconnect the azure provider.") } }
18
- }
19
- return {
20
- autoload: false,
21
- async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
22
- if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
23
- return options?.["useCompletionUrls"] ? sdk.chat(modelID) : sdk.responses(modelID)
24
- },
25
- options: { ...(endpoint ? { baseURL: endpoint } : { resourceName: resource }) },
26
- vars(_options): Record<string, string> { return resource ? { AZURE_RESOURCE_NAME: resource } : {} },
27
- }
28
- }),
29
- "azure-cognitive-services": Effect.fnUntraced(function* () {
30
- const resourceName = yield* dep.get("AZURE_COGNITIVE_SERVICES_RESOURCE_NAME")
31
- return {
32
- autoload: false,
33
- async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
34
- if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
35
- return options?.["useCompletionUrls"] ? sdk.chat(modelID) : sdk.responses(modelID)
36
- },
37
- options: { baseURL: resourceName ? `https://${resourceName}.cognitiveservices.azure.com/openai` : undefined },
38
- }
39
- }),
40
- "amazon-bedrock": Effect.fnUntraced(function* (provider: Info) {
41
- const providerConfig = (yield* dep.config()).provider?.["amazon-bedrock"]
42
- const auth = yield* dep.auth("amazon-bedrock")
43
- const env = yield* dep.env()
44
- const configRegion = providerConfig?.options?.region
45
- const envRegion = env["AWS_REGION"]
46
- const defaultRegion = configRegion ?? envRegion ?? "us-east-1"
47
- const configProfile = providerConfig?.options?.profile
48
- const envProfile = env["AWS_PROFILE"]
49
- const profile = configProfile ?? envProfile
50
- const awsAccessKeyId = env["AWS_ACCESS_KEY_ID"]
51
- const awsBearerToken = iife(() => {
52
- const envToken = process.env.AWS_BEARER_TOKEN_BEDROCK
53
- if (envToken) return envToken
54
- if (auth?.type === "api") { process.env.AWS_BEARER_TOKEN_BEDROCK = auth.key; return auth.key }
55
- return undefined
56
- })
57
- const awsWebIdentityTokenFile = env["AWS_WEB_IDENTITY_TOKEN_FILE"]
58
- const containerCreds = Boolean(process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI)
59
- if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds) return { autoload: false }
60
- const { fromNodeProviderChain } = yield* Effect.promise(() => import("@aws-sdk/credential-providers"))
61
- const providerOptions: Record<string, any> = { region: defaultRegion }
62
- if (!awsBearerToken) {
63
- const credentialProviderOptions = profile ? { profile } : {}
64
- providerOptions.credentialProvider = fromNodeProviderChain(credentialProviderOptions)
65
- }
66
- const endpoint = providerConfig?.options?.endpoint ?? providerConfig?.options?.baseURL
67
- if (endpoint) providerOptions.baseURL = endpoint
68
- return {
69
- autoload: true, options: providerOptions,
70
- async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
71
- const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
72
- if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) return sdk.languageModel(modelID)
73
- const region = options?.region ?? defaultRegion
74
- let regionPrefix = region.split("-")[0]
75
- switch (regionPrefix) {
76
- case "us": {
77
- const mReq = ["nova-micro", "nova-lite", "nova-pro", "nova-premier", "nova-2", "claude", "deepseek"].some((m) => modelID.includes(m))
78
- if (mReq && !region.startsWith("us-gov")) modelID = `${regionPrefix}.${modelID}`
79
- break
80
- }
81
- case "eu": {
82
- const rReq = ["eu-west-1", "eu-west-2", "eu-west-3", "eu-north-1", "eu-central-1", "eu-south-1", "eu-south-2"].some((r) => region.includes(r))
83
- const mReq = ["claude", "nova-lite", "nova-micro", "llama3", "pixtral"].some((m) => modelID.includes(m))
84
- if (rReq && mReq) modelID = `${regionPrefix}.${modelID}`
85
- break
86
- }
87
- case "ap": {
88
- const isAU = ["ap-southeast-2", "ap-southeast-4"].includes(region)
89
- const isTokyo = region === "ap-northeast-1"
90
- if (isAU && ["anthropic.claude-sonnet-4-5", "anthropic.claude-haiku"].some((m) => modelID.includes(m))) { regionPrefix = "au"; modelID = `${regionPrefix}.${modelID}` }
91
- else if (isTokyo) { if (["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) => modelID.includes(m))) { regionPrefix = "jp"; modelID = `${regionPrefix}.${modelID}` } }
92
- else { if (["claude", "nova-lite", "nova-micro", "nova-pro"].some((m) => modelID.includes(m))) { regionPrefix = "apac"; modelID = `${regionPrefix}.${modelID}` } }
93
- break
94
- }
95
- }
96
- return sdk.languageModel(modelID)
97
- },
98
- }
99
- }),
100
- "google-vertex": Effect.fnUntraced(function* (provider: Info) {
101
- const env = yield* dep.env()
102
- const project = provider.options?.project ?? env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"]
103
- const location = String(provider.options?.location ?? env["GOOGLE_VERTEX_LOCATION"] ?? env["GOOGLE_CLOUD_LOCATION"] ?? env["VERTEX_LOCATION"] ?? "us-central1")
104
- if (!project) return { autoload: false }
105
- return {
106
- autoload: true,
107
- vars(_options: Record<string, any>) {
108
- const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
109
- return { ...(project && { GOOGLE_VERTEX_PROJECT: project }), GOOGLE_VERTEX_LOCATION: location, GOOGLE_VERTEX_ENDPOINT: endpoint }
110
- },
111
- options: { project, location, fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
112
- const { GoogleAuth } = await import("google-auth-library")
113
- const auth = new GoogleAuth()
114
- const client = await auth.getApplicationDefault()
115
- const token = await client.credential.getAccessToken()
116
- const headers = new Headers(init?.headers)
117
- headers.set("Authorization", `Bearer ${token.token}`)
118
- return fetch(input, { ...init, headers })
119
- }},
120
- async getModel(sdk: any, modelID: string) { return sdk.languageModel(String(modelID).trim()) },
121
- }
122
- }),
123
- "google-vertex-anthropic": Effect.fnUntraced(function* () {
124
- const env = yield* dep.env()
125
- const project = env["GOOGLE_CLOUD_PROJECT"] ?? env["GCP_PROJECT"] ?? env["GCLOUD_PROJECT"]
126
- const location = env["GOOGLE_CLOUD_LOCATION"] ?? env["VERTEX_LOCATION"] ?? "global"
127
- if (!project) return { autoload: false }
128
- return { autoload: true, options: { project, location }, async getModel(sdk: any, modelID: string) { return sdk.languageModel(String(modelID).trim()) } }
129
- }),
130
- "sap-ai-core": Effect.fnUntraced(function* () {
131
- const auth = yield* dep.auth("sap-ai-core")
132
- const envServiceKey = iife(() => {
133
- const envAICoreServiceKey = process.env.AICORE_SERVICE_KEY
134
- if (envAICoreServiceKey) return envAICoreServiceKey
135
- if (auth?.type === "api") { process.env.AICORE_SERVICE_KEY = auth.key; return auth.key }
136
- return undefined
137
- })
138
- const deploymentId = process.env.AICORE_DEPLOYMENT_ID
139
- const resourceGroup = process.env.AICORE_RESOURCE_GROUP
140
- return { autoload: !!envServiceKey, options: envServiceKey ? { deploymentId, resourceGroup } : {}, async getModel(sdk: any, modelID: string) { return sdk(modelID) } }
141
- }),
142
- "cloudflare-workers-ai": Effect.fnUntraced(function* (input: Info) {
143
- if (input.options?.baseURL) return { autoload: false }
144
- const auth = yield* dep.auth(input.id)
145
- const env = yield* dep.env()
146
- const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
147
- if (!accountId) return { autoload: false, async getModel() { throw new Error("CLOUDFLARE_ACCOUNT_ID is missing. Set it with: export CLOUDFLARE_ACCOUNT_ID=<your-account-id>") } }
148
- const apiKey = yield* Effect.gen(function* () { const envToken = env["CLOUDFLARE_API_KEY"]; if (envToken) return envToken; if (auth?.type === "api") return auth.key; return undefined })
149
- return { autoload: !!apiKey, options: { apiKey, headers: { "User-Agent": `saeeol/${InstallationVersion} cloudflare-workers-ai (${process.platform}; ${process.arch})` } }, async getModel(sdk: any, modelID: string) { return sdk.languageModel(modelID) }, vars(_options) { return { CLOUDFLARE_ACCOUNT_ID: accountId } } }
150
- }),
151
- "cloudflare-ai-gateway": Effect.fnUntraced(function* (input: Info) {
152
- if (input.options?.baseURL) return { autoload: false }
153
- const auth = yield* dep.auth(input.id)
154
- const env = yield* dep.env()
155
- const accountId = env["CLOUDFLARE_ACCOUNT_ID"] || (auth?.type === "api" ? auth.metadata?.accountId : undefined)
156
- const gateway = env["CLOUDFLARE_GATEWAY_ID"] || (auth?.type === "api" ? auth.metadata?.gatewayId : undefined)
157
- if (!accountId || !gateway) {
158
- const missing = [!accountId ? "CLOUDFLARE_ACCOUNT_ID" : undefined, !gateway ? "CLOUDFLARE_GATEWAY_ID" : undefined].filter((x): x is string => Boolean(x))
159
- return { autoload: false, async getModel() { throw new Error(`${missing.join(" and ")} missing. Set with: ${missing.map((x) => `export ${x}=<value>`).join(" && ")}`) } }
160
- }
161
- const apiToken = yield* Effect.gen(function* () { const envToken = env["CLOUDFLARE_API_TOKEN"] || env["CF_AIG_TOKEN"]; if (envToken) return envToken; if (auth?.type === "api") return auth.key; return undefined })
162
- if (!apiToken) throw new Error("CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required for Cloudflare AI Gateway.")
163
- const { createAiGateway } = yield* Effect.promise(() => import("ai-gateway-provider"))
164
- const { createUnified } = yield* Effect.promise(() => import("ai-gateway-provider/providers/unified"))
165
- const metadata = iife(() => { if (input.options?.metadata) return input.options.metadata; try { return JSON.parse(input.options?.headers?.["cf-aig-metadata"]) } catch { return undefined } })
166
- const aigateway = createAiGateway({ accountId, gateway, apiKey: apiToken })
167
- const unified = createUnified()
168
- return { autoload: true, async getModel(_sdk: any, modelID: string) { return aigateway(unified(modelID)) }, options: {} }
169
- }),
170
- }
171
- }
@@ -1,77 +0,0 @@
1
- /** GitLab 커스텀 로더 — provider.ts에서 분리 */
2
-
3
- import os from "os"
4
- import { Effect } from "effect"
5
- import * as Log from "@saeeol/core/util/log"
6
- import { InstallationVersion } from "@saeeol/core/installation/version"
7
- import { InstanceState } from "@/effect/instance-state"
8
- import { ModelID, ProviderID } from "./schema"
9
- import type { CustomLoader, CustomDep } from "./provider-custom-loaders"
10
- import type { Info, Model } from "./provider-schemas"
11
-
12
- const log = Log.create({ service: "provider" })
13
-
14
- export function customGitlab(dep: CustomDep): Record<string, CustomLoader> {
15
- return {
16
- gitlab: Effect.fnUntraced(function* (input: Info) {
17
- const { VERSION: GITLAB_PROVIDER_VERSION, isWorkflowModel, discoverWorkflowModels } = yield* Effect.promise(() => import("gitlab-ai-provider"))
18
- const instanceUrl = (yield* dep.get("GITLAB_INSTANCE_URL")) || "https://gitlab.com"
19
- const auth = yield* dep.auth(input.id)
20
- const apiKey = yield* Effect.sync(() => {
21
- if (auth?.type === "oauth") return auth.access
22
- if (auth?.type === "api") return auth.key
23
- return undefined
24
- })
25
- const token = apiKey ?? (yield* dep.get("GITLAB_TOKEN"))
26
- const providerConfig = (yield* dep.config()).provider?.["gitlab"]
27
- const directory = yield* InstanceState.directory
28
- const aiGatewayHeaders = {
29
- "User-Agent": `saeeol/${InstallationVersion} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
30
- "anthropic-beta": "context-1m-2025-08-07",
31
- ...providerConfig?.options?.aiGatewayHeaders,
32
- }
33
- const featureFlags = { duo_agent_platform_agentic_chat: true, duo_agent_platform: true, ...providerConfig?.options?.featureFlags }
34
- return {
35
- autoload: !!token,
36
- options: { instanceUrl, apiKey: token, aiGatewayHeaders, featureFlags },
37
- async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
38
- if (modelID.startsWith("duo-workflow-")) {
39
- const workflowRef = typeof options?.workflowRef === "string" ? options.workflowRef : undefined
40
- const sdkModelID = isWorkflowModel(modelID) ? modelID : "duo-workflow"
41
- const workflowDefinition = typeof options?.workflowDefinition === "string" ? options.workflowDefinition : undefined
42
- const model = sdk.workflowChat(sdkModelID, { featureFlags, workflowDefinition })
43
- if (workflowRef) model.selectedModelRef = workflowRef
44
- return model
45
- }
46
- return sdk.agenticChat(modelID, { aiGatewayHeaders, featureFlags })
47
- },
48
- async discoverModels(): Promise<Record<string, Model>> {
49
- if (!apiKey) { log.info("gitlab model discovery skipped: no apiKey"); return {} }
50
- try {
51
- const getHeaders = (): Record<string, string> => auth?.type === "api" ? { "PRIVATE-TOKEN": token! } : { Authorization: `Bearer ${token!}` }
52
- log.info("gitlab model discovery starting", { instanceUrl })
53
- const result = await discoverWorkflowModels({ instanceUrl, getHeaders }, { workingDirectory: directory })
54
- if (!result.models.length) {
55
- log.info("gitlab model discovery skipped: no models found", { project: result.project ? { id: result.project.id, path: result.project.pathWithNamespace } : null })
56
- return {}
57
- }
58
- const models: Record<string, Model> = {}
59
- for (const m of result.models) {
60
- if (!input.models[m.id]) {
61
- models[m.id] = {
62
- id: ModelID.make(m.id), providerID: ProviderID.make("gitlab"), name: `Agent Platform (${m.name})`, family: "",
63
- api: { id: m.id, url: instanceUrl, npm: "gitlab-ai-provider" }, status: "active", headers: {}, options: { workflowRef: m.ref },
64
- cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, limit: { context: m.context, output: m.output },
65
- capabilities: { temperature: false, reasoning: true, attachment: true, toolcall: true, input: { text: true, audio: false, image: true, video: false, pdf: true }, output: { text: true, audio: false, image: false, video: false, pdf: false }, interleaved: false },
66
- release_date: "", variants: {},
67
- }
68
- }
69
- }
70
- log.info("gitlab model discovery complete", { count: Object.keys(models).length, models: Object.keys(models) })
71
- return models
72
- } catch (e) { log.warn("gitlab model discovery failed", { error: e }); return {} }
73
- },
74
- }
75
- }),
76
- }
77
- }
@@ -1,82 +0,0 @@
1
- /** 커스텀 로더 타입 + 심플 로더 — provider.ts에서 분리 */
2
-
3
- import { Effect } from "effect"
4
- import { iife } from "@/util/iife"
5
- import type { Info } from "./provider-schemas"
6
- import type { Model } from "./provider-schemas"
7
- import type { Config } from "@/config/config"
8
- import { Auth } from "../auth"
9
- import { shouldUseCopilotResponsesApi } from "./bundled-providers"
10
-
11
- export type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
12
- export type CustomVarsLoader = (options: Record<string, any>) => Record<string, string>
13
- export type CustomDiscoverModels = () => Promise<Record<string, Model>>
14
- export type CustomLoader = (provider: Info) => Effect.Effect<{
15
- autoload: boolean
16
- getModel?: CustomModelLoader
17
- vars?: CustomVarsLoader
18
- options?: Record<string, any>
19
- discoverModels?: CustomDiscoverModels
20
- }>
21
-
22
- export type CustomDep = {
23
- auth: (id: string) => Effect.Effect<Auth.Info | undefined>
24
- config: () => Effect.Effect<Config.Info>
25
- env: () => Effect.Effect<Record<string, string | undefined>>
26
- get: (key: string) => Effect.Effect<string | undefined>
27
- }
28
-
29
- export function useLanguageModel(sdk: any) {
30
- return sdk.responses === undefined && sdk.chat === undefined
31
- }
32
-
33
- export function custom(dep: CustomDep): Record<string, CustomLoader> {
34
- return {
35
- anthropic: () =>
36
- Effect.succeed({
37
- autoload: false,
38
- options: {
39
- headers: {
40
- "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
41
- },
42
- },
43
- }),
44
- saeeol: Effect.fnUntraced(function* (input: Info) {
45
- const env = yield* dep.env()
46
- const hasKey = iife(() => input.env.some((item) => env[item]))
47
- const ok = hasKey || Boolean(yield* dep.auth(input.id)) || Boolean((yield* dep.config()).provider?.["saeeol"]?.options?.apiKey)
48
- if (!ok) {
49
- for (const [key, value] of Object.entries(input.models)) {
50
- if (value.cost.input === 0) continue
51
- delete input.models[key]
52
- }
53
- }
54
- return { autoload: Object.keys(input.models).length > 0, options: ok ? {} : { apiKey: "public" } }
55
- }),
56
- openai: () =>
57
- Effect.succeed({ autoload: false, async getModel(sdk: any, modelID: string) { return sdk.responses(modelID) }, options: {} }),
58
- xai: () =>
59
- Effect.succeed({ autoload: false, async getModel(sdk: any, modelID: string) { return sdk.responses(modelID) }, options: {} }),
60
- "github-copilot": () =>
61
- Effect.succeed({
62
- autoload: false,
63
- async getModel(sdk: any, modelID: string) {
64
- if (useLanguageModel(sdk)) return sdk.languageModel(modelID)
65
- return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID)
66
- },
67
- options: {},
68
- }),
69
- llmgateway: () =>
70
- Effect.succeed({ autoload: false, options: { headers: { "HTTP-Referer": "https://saeeol.ai/", "X-Title": "SAEEOL", "X-Source": "saeeol" } } }),
71
- openrouter: () =>
72
- Effect.succeed({ autoload: false, options: { headers: { "HTTP-Referer": "https://saeeol.ai/", "X-Title": "SAEEOL" } } }),
73
- nvidia: () =>
74
- Effect.succeed({ autoload: false, options: { headers: { "HTTP-Referer": "https://saeeol.ai/", "X-Title": "SAEEOL", "X-BILLING-INVOKE-ORIGIN": "SaeeolCode" } } }),
75
- vercel: () =>
76
- Effect.succeed({ autoload: false, options: { headers: { "http-referer": "https://saeeol.ai/", "x-title": "SAEEOL" } } }),
77
- cerebras: () =>
78
- Effect.succeed({ autoload: false, options: { headers: { "X-Cerebras-3rd-Party-Integration": "SAEEOL" } } }),
79
- zenmux: () =>
80
- Effect.succeed({ autoload: false, options: { headers: { "HTTP-Referer": "https://saeeol.ai/", "X-Title": "SAEEOL" } } }),
81
- }
82
- }