saeeol 1.0.0 → 1.0.2

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,9 +1,13 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "name": "saeeol",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/byfabulist/saeeol"
10
+ },
7
11
  "publishConfig": {
8
12
  "access": "public",
9
13
  "registry": "https://registry.npmjs.org"
@@ -0,0 +1,325 @@
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 { Auth } from "../../auth"
9
+ import { AppRuntime } from "../../effect/app-runtime"
10
+ import { Config } from "@/config/config"
11
+ import { Effect } from "effect"
12
+ import { put } from "./providers-auth"
13
+
14
+ const { TEXT_HIGHLIGHT: H, TEXT_NORMAL: N, TEXT_DIM: D, TEXT_SUCCESS: S, TEXT_NORMAL_BOLD: B, TEXT_WARNING: W } = UI.Style
15
+
16
+ interface Preset {
17
+ id: string
18
+ label: string
19
+ provider: string
20
+ envVar: string
21
+ models: Array<{ id: string; label: string }>
22
+ hint: string
23
+ defaultModel?: string
24
+ }
25
+
26
+ const presets: Preset[] = [
27
+ {
28
+ id: "anthropic",
29
+ label: "Anthropic (Claude)",
30
+ provider: "anthropic",
31
+ envVar: "ANTHROPIC_API_KEY",
32
+ models: [
33
+ { id: "anthropic/claude-sonnet-4", label: "Claude Sonnet 4 (추천)" },
34
+ { id: "anthropic/claude-haiku-4-5", label: "Claude Haiku 4.5 (빠름/저렴)" },
35
+ { id: "anthropic/claude-opus-4", label: "Claude Opus 4 (강력)" },
36
+ ],
37
+ hint: "https://console.anthropic.com/settings/keys",
38
+ defaultModel: "anthropic/claude-sonnet-4",
39
+ },
40
+ {
41
+ id: "openai",
42
+ label: "OpenAI (GPT)",
43
+ provider: "openai",
44
+ envVar: "OPENAI_API_KEY",
45
+ models: [
46
+ { id: "openai/gpt-5", label: "GPT-5" },
47
+ { id: "openai/gpt-5-mini", label: "GPT-5 Mini (빠름)" },
48
+ { id: "openai/o3", label: "o3 (추론)" },
49
+ ],
50
+ hint: "https://platform.openai.com/api-keys",
51
+ },
52
+ {
53
+ id: "google",
54
+ label: "Google (Gemini)",
55
+ provider: "google",
56
+ envVar: "GOOGLE_GENERATIVE_AI_API_KEY",
57
+ models: [
58
+ { id: "google/gemini-2.5-pro", label: "Gemini 2.5 Pro" },
59
+ { id: "google/gemini-2.5-flash", label: "Gemini 2.5 Flash (빠름)" },
60
+ ],
61
+ hint: "https://aistudio.google.com/apikey",
62
+ },
63
+ {
64
+ id: "openrouter",
65
+ label: "OpenRouter (멀티 provider)",
66
+ provider: "openrouter",
67
+ envVar: "OPENROUTER_API_KEY",
68
+ models: [
69
+ { id: "openrouter/anthropic/claude-sonnet-4", label: "Claude Sonnet 4 via OpenRouter" },
70
+ { id: "openrouter/openai/gpt-5", label: "GPT-5 via OpenRouter" },
71
+ { id: "openrouter/google/gemini-2.5-pro", label: "Gemini 2.5 Pro via OpenRouter" },
72
+ ],
73
+ hint: "https://openrouter.ai/keys",
74
+ },
75
+ {
76
+ id: "groq",
77
+ label: "Groq (초고속 추론)",
78
+ provider: "groq",
79
+ envVar: "GROQ_API_KEY",
80
+ models: [
81
+ { id: "groq/llama-4-maverick-17b-128e-instruct", label: "Llama 4 Maverick" },
82
+ ],
83
+ hint: "https://console.groq.com/keys",
84
+ },
85
+ {
86
+ id: "github-copilot",
87
+ label: "GitHub Copilot (무료)",
88
+ provider: "github-copilot",
89
+ envVar: "",
90
+ models: [
91
+ { id: "github-copilot/gpt-5", label: "GPT-5 via Copilot" },
92
+ { id: "github-copilot/claude-sonnet-4", label: "Claude Sonnet 4 via Copilot" },
93
+ ],
94
+ hint: "GitHub 계정으로 OAuth 로그인",
95
+ },
96
+ {
97
+ id: "bedrock",
98
+ label: "Amazon Bedrock",
99
+ provider: "amazon-bedrock",
100
+ envVar: "AWS_ACCESS_KEY_ID",
101
+ models: [
102
+ { id: "amazon-bedrock/anthropic.claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
103
+ ],
104
+ hint: "AWS 자격 증명 필요",
105
+ },
106
+ {
107
+ id: "custom",
108
+ label: "직접 입력 (OpenAI 호환)",
109
+ provider: "",
110
+ envVar: "",
111
+ models: [],
112
+ hint: "",
113
+ },
114
+ ]
115
+
116
+ export const InitCommand = cmd({
117
+ command: "init",
118
+ describe: "최초 설정 — provider, API key, 모델 선택",
119
+ builder: (y) =>
120
+ y
121
+ .option("preset", {
122
+ type: "string",
123
+ describe: "프리셋 ID (anthropic, openai, google, openrouter, groq, github-copilot, bedrock, custom)",
124
+ choices: presets.map((p) => p.id),
125
+ })
126
+ .option("api-key", {
127
+ type: "string",
128
+ describe: "API key (인터랙티브 입력 스킵)",
129
+ })
130
+ .option("model", {
131
+ type: "string",
132
+ describe: "기본 모델 (예: anthropic/claude-sonnet-4)",
133
+ })
134
+ .option("base-url", {
135
+ type: "string",
136
+ describe: "커스텀 API base URL (custom 프리셋용)",
137
+ })
138
+ .option("yes", {
139
+ type: "boolean",
140
+ alias: "y",
141
+ describe: "기본값으로 자동 진행",
142
+ default: false,
143
+ }),
144
+ handler: async (args) => {
145
+ await Instance.provide({
146
+ directory: process.cwd(),
147
+ async fn() {
148
+ UI.empty()
149
+ UI.println(logo())
150
+ UI.empty()
151
+ prompts.intro(`${B}SAEEOL 초기 설정${N}`)
152
+ UI.empty()
153
+
154
+ // 1. Preset 선택
155
+ const preset = resolvePreset(args.preset)
156
+ const selected = preset ?? (await promptPreset())
157
+ if (!selected) return
158
+
159
+ // 2. API Key
160
+ if (selected.id === "github-copilot") {
161
+ prompts.log.info("GitHub Copilot은 OAuth 로그인이 필요합니다.")
162
+ prompts.log.info("나중에 ${H}saeeol auth login -p github-copilot${N} 으로 로그인하세요.")
163
+ } else if (selected.id === "custom") {
164
+ await handleCustom(args)
165
+ return
166
+ } else if (selected.envVar) {
167
+ const existingKey = process.env[selected.envVar]
168
+ if (existingKey) {
169
+ prompts.log.info(`${D}${selected.envVar} 환경변수가 이미 설정되어 있습니다.${N}`)
170
+ } else {
171
+ const key = args["api-key"] ?? (await promptApiKey(selected))
172
+ if (!key) return
173
+ await put(selected.provider, { type: "api", key })
174
+ prompts.log.success("API key 저장 완료")
175
+ }
176
+ }
177
+
178
+ // 3. 모델 선택
179
+ const model = args.model ?? (await promptModel(selected))
180
+ if (!model) return
181
+
182
+ // 4. 설정 파일 저장
183
+ await saveConfig(selected, model, args["base-url"])
184
+
185
+ UI.empty()
186
+ prompts.outro(`${S}설정 완료!${N}`)
187
+ UI.empty()
188
+ UI.println(` ${D}시작하기:${N}`)
189
+ UI.println(` ${H}saeeol${N} ${D}# 대화형 세션 시작${N}`)
190
+ UI.println(` ${H}saeeol run${N} ${D}# 프롬프트 바로 실행${N}`)
191
+ UI.empty()
192
+ },
193
+ })
194
+ },
195
+ })
196
+
197
+ function logo(): string {
198
+ const lines = [
199
+ " ██████╗ █████╗ ███████╗███████╗██╗ ██╗",
200
+ " ██╔══██╗██╔══██╗██╔════╝██╔════╝██║ ██╔╝",
201
+ " ██████╔╝███████║███████╗███████╗█████╔╝ ",
202
+ " ██╔═══╝ ██╔══██║╚════██║╚════██║██╔═██╗ ",
203
+ " ██║ ██║ ██║███████║███████║██║ ██╗",
204
+ " ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝",
205
+ ]
206
+ return lines.map((l) => `${H}${l}${N}`).join("\n")
207
+ }
208
+
209
+ function resolvePreset(id?: string): Preset | undefined {
210
+ if (!id) return
211
+ return presets.find((p) => p.id === id)
212
+ }
213
+
214
+ async function promptPreset(): Promise<Preset | undefined> {
215
+ const selected = await prompts.select({
216
+ message: "사용할 AI Provider를 선택하세요",
217
+ options: presets.map((p) => ({
218
+ label: p.label,
219
+ value: p.id,
220
+ hint: p.id === "custom" ? "OpenAI 호환 API" : p.provider,
221
+ })),
222
+ })
223
+ if (prompts.isCancel(selected)) throw new UI.CancelledError()
224
+ return presets.find((p) => p.id === selected)
225
+ }
226
+
227
+ async function promptApiKey(preset: Preset): Promise<string | undefined> {
228
+ prompts.log.info(`${D}API key 발급: ${preset.hint}${N}`)
229
+ const key = await prompts.password({
230
+ message: `${preset.label} API Key를 입력하세요`,
231
+ validate: (x) => (x && x.length > 0 ? undefined : "필수 입력입니다"),
232
+ })
233
+ if (prompts.isCancel(key)) throw new UI.CancelledError()
234
+ return key
235
+ }
236
+
237
+ async function promptModel(preset: Preset): Promise<string | undefined> {
238
+ if (preset.models.length === 0) return
239
+ if (preset.models.length === 1 && preset.defaultModel) return preset.defaultModel
240
+
241
+ const selected = await prompts.select({
242
+ message: "기본 모델을 선택하세요",
243
+ options: preset.models.map((m) => ({
244
+ label: m.label,
245
+ value: m.id,
246
+ })),
247
+ })
248
+ if (prompts.isCancel(selected)) throw new UI.CancelledError()
249
+ return selected as string
250
+ }
251
+
252
+ async function handleCustom(args: Record<string, unknown>) {
253
+ const baseUrl = (args["base-url"] as string) ?? (await prompts.text({
254
+ message: "API Base URL을 입력하세요",
255
+ placeholder: "https://api.example.com/v1",
256
+ validate: (x) => (x && x.startsWith("http") ? undefined : "http:// 또는 https:// 로 시작해야 합니다"),
257
+ }))
258
+ if (prompts.isCancel(baseUrl)) throw new UI.CancelledError()
259
+
260
+ const key = (args["api-key"] as string) ?? (await prompts.password({
261
+ message: "API Key를 입력하세요",
262
+ validate: (x) => (x && x.length > 0 ? undefined : "필수 입력입니다"),
263
+ }))
264
+ if (prompts.isCancel(key)) throw new UI.CancelledError()
265
+
266
+ const modelId = (args.model as string) ?? (await prompts.text({
267
+ message: "기본 모델 ID를 입력하세요",
268
+ placeholder: "custom/my-model",
269
+ validate: (x) => (x && x.length > 0 ? undefined : "필수 입력입니다"),
270
+ }))
271
+ if (prompts.isCancel(modelId)) throw new UI.CancelledError()
272
+
273
+ const providerId = modelId.split("/")[0] || "custom"
274
+ await put(providerId, { type: "api", key })
275
+
276
+ const config = buildConfigObj(modelId, { [providerId]: { api: baseUrl } })
277
+ await saveConfigFile(config)
278
+
279
+ UI.empty()
280
+ prompts.outro(`${S}설정 완료!${N}`)
281
+ }
282
+
283
+ function buildConfigObj(model: string, provider?: Record<string, unknown>) {
284
+ const obj: Record<string, unknown> = {
285
+ $schema: "https://app.saeeol.ai/config.json",
286
+ model,
287
+ }
288
+ if (provider && Object.keys(provider).length > 0) {
289
+ obj.provider = provider
290
+ }
291
+ return obj
292
+ }
293
+
294
+ async function saveConfig(preset: Preset, model: string, baseUrl?: string) {
295
+ const provider: Record<string, unknown> = {}
296
+ if (baseUrl) {
297
+ const pid = model.split("/")[0] || preset.provider
298
+ provider[pid] = { api: baseUrl }
299
+ }
300
+ const config = buildConfigObj(model, provider)
301
+ await saveConfigFile(config)
302
+ }
303
+
304
+ async function saveConfigFile(config: Record<string, unknown>) {
305
+ const configPath = path.join(Global.Path.config, "saeeol.json")
306
+ const existing = await fs.readFile(configPath, "utf-8").catch(() => null)
307
+
308
+ if (existing) {
309
+ try {
310
+ const parsed = JSON.parse(existing)
311
+ const merged = { ...parsed, ...config }
312
+ if (parsed.provider && config.provider) {
313
+ merged.provider = { ...parsed.provider, ...(config.provider as Record<string, unknown>) }
314
+ }
315
+ await fs.writeFile(configPath, JSON.stringify(merged, null, 2) + "\n", "utf-8")
316
+ } catch {
317
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
318
+ }
319
+ } else {
320
+ await fs.mkdir(Global.Path.config, { recursive: true })
321
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
322
+ }
323
+
324
+ prompts.log.success(`설정 파일: ${D}${configPath}${N}`)
325
+ }
@@ -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
- }