@lupaflow/providers 0.1.0

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 ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@lupaflow/providers",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "require": "./dist/index.js",
16
+ "import": "./dist/index.js"
17
+ }
18
+ },
19
+ "scripts": {
20
+ "build": "tsup",
21
+ "dev": "tsup --watch"
22
+ },
23
+ "dependencies": {
24
+ "@lupaflow/core": "workspace:*",
25
+ "@lupaflow/secrets": "workspace:*",
26
+ "@lupaflow/types": "workspace:*",
27
+ "openai": "^4.68.0",
28
+ "@google/generative-ai": "^0.21.0"
29
+ },
30
+ "devDependencies": {
31
+ "tsup": "^8.3.0",
32
+ "typescript": "^5.6.0"
33
+ }
34
+ }
package/src/google.ts ADDED
@@ -0,0 +1,193 @@
1
+ import { GoogleGenerativeAI } from "@google/generative-ai"
2
+ import type { LLMProvider } from "./interface"
3
+ import type { CompletionRequest, CompletionResponse, StreamChunk, ProviderConfig } from "@lupaflow/types"
4
+ import { ProviderError } from "@lupaflow/core"
5
+ import { getGoogleKey } from "@lupaflow/secrets"
6
+ import { globalEventBus } from "@lupaflow/core"
7
+
8
+ export class GoogleProvider implements LLMProvider {
9
+ readonly name = "google"
10
+ readonly defaultModel = "gemini-1.5-flash"
11
+ config: ProviderConfig
12
+
13
+ private client: GoogleGenerativeAI
14
+
15
+ constructor(config: ProviderConfig = {}) {
16
+ this.config = config
17
+ this.client = new GoogleGenerativeAI(config.apiKey || getGoogleKey())
18
+ }
19
+
20
+ validateConfig(): boolean {
21
+ try {
22
+ getGoogleKey()
23
+ return true
24
+ } catch {
25
+ return false
26
+ }
27
+ }
28
+
29
+ private buildContents(request: CompletionRequest): { role: string; parts: { text: string }[] }[] {
30
+ const contents: { role: string; parts: { text: string }[] }[] = []
31
+ for (const msg of request.messages) {
32
+ if (msg.role === "system") continue
33
+ const role = msg.role === "assistant" ? "model" : "user"
34
+ contents.push({
35
+ role,
36
+ parts: [{ text: msg.content || "" }],
37
+ })
38
+ }
39
+ return contents
40
+ }
41
+
42
+ async complete(request: CompletionRequest): Promise<CompletionResponse> {
43
+ const start = Date.now()
44
+ const model = request.model || this.config.model || this.defaultModel
45
+
46
+ await globalEventBus.emit("provider:start", {
47
+ provider: this.name,
48
+ model,
49
+ } as any)
50
+
51
+ try {
52
+ const genModel = this.client.getGenerativeModel({ model })
53
+
54
+ const systemInstruction = request.systemPrompt
55
+ const contents = this.buildContents(request)
56
+
57
+ const requestOptions: any = {
58
+ contents,
59
+ generationConfig: {
60
+ temperature: request.temperature ?? this.config.temperature,
61
+ maxOutputTokens: request.maxTokens ?? this.config.maxTokens,
62
+ topP: request.topP ?? this.config.topP,
63
+ },
64
+ }
65
+
66
+ if (systemInstruction) {
67
+ requestOptions.systemInstruction = {
68
+ role: "user",
69
+ parts: [{ text: systemInstruction }],
70
+ }
71
+ }
72
+
73
+ const result = await genModel.generateContent(requestOptions)
74
+
75
+ const latencyMs = Date.now() - start
76
+ const response = result.response
77
+ const text = response.text()
78
+
79
+ const usage: any = response.usageMetadata || {}
80
+ const totalTokens = (usage.promptTokenCount || 0) + (usage.candidatesTokenCount || 0)
81
+
82
+ const completionResponse: CompletionResponse = {
83
+ content: text,
84
+ toolCalls: [],
85
+ usage: {
86
+ promptTokens: usage.promptTokenCount || 0,
87
+ completionTokens: usage.candidatesTokenCount || 0,
88
+ totalTokens,
89
+ },
90
+ model,
91
+ latencyMs,
92
+ finishReason: "stop",
93
+ }
94
+
95
+ await globalEventBus.emit("provider:complete", {
96
+ provider: this.name,
97
+ model,
98
+ tokens: totalTokens,
99
+ latencyMs,
100
+ } as any)
101
+
102
+ return completionResponse
103
+ } catch (err: any) {
104
+ const latencyMs = Date.now() - start
105
+ await globalEventBus.emit("provider:error", {
106
+ provider: this.name,
107
+ model,
108
+ error: err.message,
109
+ } as any)
110
+ throw new ProviderError(this.name, err.message, { model, latencyMs })
111
+ }
112
+ }
113
+
114
+ async *streamComplete(request: CompletionRequest): AsyncIterable<StreamChunk> {
115
+ const start = Date.now()
116
+ const model = request.model || this.config.model || this.defaultModel
117
+
118
+ await globalEventBus.emit("provider:start", {
119
+ provider: this.name,
120
+ model,
121
+ } as any)
122
+
123
+ try {
124
+ const genModel = this.client.getGenerativeModel({ model })
125
+
126
+ const systemInstruction = request.systemPrompt
127
+ const contents = this.buildContents(request)
128
+
129
+ const requestOptions: any = {
130
+ contents,
131
+ generationConfig: {
132
+ temperature: request.temperature ?? this.config.temperature,
133
+ maxOutputTokens: request.maxTokens ?? this.config.maxTokens,
134
+ topP: request.topP ?? this.config.topP,
135
+ },
136
+ }
137
+
138
+ if (systemInstruction) {
139
+ requestOptions.systemInstruction = {
140
+ role: "user",
141
+ parts: [{ text: systemInstruction }],
142
+ }
143
+ }
144
+
145
+ const result = await genModel.generateContentStream(requestOptions)
146
+
147
+ let fullContent = ""
148
+
149
+ for await (const chunk of result.stream) {
150
+ const text = chunk.text()
151
+ if (text) {
152
+ fullContent += text
153
+ yield { type: "text", text }
154
+ }
155
+ }
156
+
157
+ const response = (await result.response)
158
+ const usage: any = response.usageMetadata || {}
159
+ const totalTokens = (usage.promptTokenCount || 0) + (usage.candidatesTokenCount || 0)
160
+
161
+ yield {
162
+ type: "finish",
163
+ content: fullContent,
164
+ toolCalls: [],
165
+ usage: {
166
+ promptTokens: usage.promptTokenCount || 0,
167
+ completionTokens: usage.candidatesTokenCount || 0,
168
+ totalTokens,
169
+ },
170
+ model,
171
+ finishReason: "stop",
172
+ }
173
+
174
+ await globalEventBus.emit("provider:complete", {
175
+ provider: this.name,
176
+ model,
177
+ tokens: totalTokens,
178
+ latencyMs: Date.now() - start,
179
+ } as any)
180
+ } catch (err: any) {
181
+ await globalEventBus.emit("provider:error", {
182
+ provider: this.name,
183
+ model,
184
+ error: err.message,
185
+ } as any)
186
+ throw new ProviderError(this.name, err.message, { model, latencyMs: Date.now() - start })
187
+ }
188
+ }
189
+
190
+ async getModels(): Promise<string[]> {
191
+ return ["gemini-1.5-flash", "gemini-1.5-pro", "gemini-2.0-flash-exp"]
192
+ }
193
+ }
package/src/groq.ts ADDED
@@ -0,0 +1,235 @@
1
+ import OpenAI from "openai"
2
+ import type { LLMProvider } from "./interface"
3
+ import type { CompletionRequest, CompletionResponse, StreamChunk, ProviderConfig } from "@lupaflow/types"
4
+ import { ProviderError } from "@lupaflow/core"
5
+ import { getGroqKey } from "@lupaflow/secrets"
6
+ import { globalEventBus } from "@lupaflow/core"
7
+
8
+ export class GroqProvider implements LLMProvider {
9
+ readonly name = "groq"
10
+ readonly defaultModel = "llama-3.3-70b-versatile"
11
+ config: ProviderConfig
12
+
13
+ private client: OpenAI
14
+
15
+ constructor(config: ProviderConfig = {}) {
16
+ this.config = config
17
+ this.client = new OpenAI({
18
+ apiKey: config.apiKey || getGroqKey(),
19
+ baseURL: config.baseUrl || "https://api.groq.com/openai/v1",
20
+ })
21
+ }
22
+
23
+ validateConfig(): boolean {
24
+ try {
25
+ getGroqKey()
26
+ return true
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+
32
+ private buildMessages(request: CompletionRequest): OpenAI.Chat.ChatCompletionMessageParam[] {
33
+ const messages: OpenAI.Chat.ChatCompletionMessageParam[] = []
34
+ if (request.systemPrompt) {
35
+ messages.push({ role: "system", content: request.systemPrompt })
36
+ }
37
+ for (const m of request.messages) {
38
+ if (m.role === "assistant" && m.toolCalls?.length) {
39
+ messages.push({
40
+ role: "assistant",
41
+ content: m.content as string | null,
42
+ tool_calls: m.toolCalls as any,
43
+ })
44
+ } else if (m.role === "tool") {
45
+ messages.push({
46
+ role: "tool",
47
+ tool_call_id: m.toolCallId || "",
48
+ content: m.content || "",
49
+ })
50
+ } else {
51
+ messages.push({ role: m.role as any, content: m.content })
52
+ }
53
+ }
54
+ return messages
55
+ }
56
+
57
+ private buildTools(
58
+ tools?: CompletionRequest["tools"]
59
+ ): OpenAI.Chat.ChatCompletionTool[] | undefined {
60
+ return tools?.map((t) => ({
61
+ type: "function" as const,
62
+ function: {
63
+ name: t.definition.name,
64
+ description: t.definition.description,
65
+ parameters: {
66
+ type: "object",
67
+ properties: t.definition.parameters,
68
+ required: t.definition.required,
69
+ },
70
+ },
71
+ }))
72
+ }
73
+
74
+ async complete(request: CompletionRequest): Promise<CompletionResponse> {
75
+ const start = Date.now()
76
+ const model = request.model || this.config.model || this.defaultModel
77
+
78
+ await globalEventBus.emit("provider:start", {
79
+ provider: this.name,
80
+ model,
81
+ } as any)
82
+
83
+ try {
84
+ const messages = this.buildMessages(request)
85
+ const tools = this.buildTools(request.tools)
86
+
87
+ const response = await this.client.chat.completions.create({
88
+ model,
89
+ messages,
90
+ tools,
91
+ temperature: request.temperature ?? this.config.temperature,
92
+ max_tokens: request.maxTokens ?? this.config.maxTokens,
93
+ top_p: request.topP ?? this.config.topP,
94
+ })
95
+
96
+ const choice = response.choices[0]
97
+ const latencyMs = Date.now() - start
98
+
99
+ const result: CompletionResponse = {
100
+ content: choice.message.content,
101
+ toolCalls: (choice.message.tool_calls || []).map((tc) => ({
102
+ id: tc.id,
103
+ name: tc.function.name,
104
+ args: JSON.parse(tc.function.arguments),
105
+ })),
106
+ usage: {
107
+ promptTokens: response.usage?.prompt_tokens || 0,
108
+ completionTokens: response.usage?.completion_tokens || 0,
109
+ totalTokens: response.usage?.total_tokens || 0,
110
+ },
111
+ model: response.model,
112
+ latencyMs,
113
+ finishReason: choice.finish_reason || "stop",
114
+ }
115
+
116
+ await globalEventBus.emit("provider:complete", {
117
+ provider: this.name,
118
+ model,
119
+ tokens: result.usage.totalTokens,
120
+ latencyMs,
121
+ } as any)
122
+
123
+ return result
124
+ } catch (err: any) {
125
+ const latencyMs = Date.now() - start
126
+ await globalEventBus.emit("provider:error", {
127
+ provider: this.name,
128
+ model,
129
+ error: err.message,
130
+ } as any)
131
+ throw new ProviderError(this.name, err.message, { model, latencyMs })
132
+ }
133
+ }
134
+
135
+ async *streamComplete(request: CompletionRequest): AsyncIterable<StreamChunk> {
136
+ const start = Date.now()
137
+ const model = request.model || this.config.model || this.defaultModel
138
+
139
+ await globalEventBus.emit("provider:start", {
140
+ provider: this.name,
141
+ model,
142
+ } as any)
143
+
144
+ try {
145
+ const messages = this.buildMessages(request)
146
+ const tools = this.buildTools(request.tools)
147
+
148
+ const stream = await this.client.chat.completions.create({
149
+ model,
150
+ messages,
151
+ tools,
152
+ temperature: request.temperature ?? this.config.temperature,
153
+ max_tokens: request.maxTokens ?? this.config.maxTokens,
154
+ top_p: request.topP ?? this.config.topP,
155
+ stream: true,
156
+ })
157
+
158
+ let fullContent = ""
159
+ const accToolCalls = new Map<number, { id: string; name: string; args: string }>()
160
+
161
+ for await (const chunk of stream) {
162
+ const delta = chunk.choices[0]?.delta
163
+
164
+ if (delta?.content) {
165
+ fullContent += delta.content
166
+ yield { type: "text", text: delta.content }
167
+ }
168
+
169
+ if (delta?.tool_calls) {
170
+ for (const tc of delta.tool_calls) {
171
+ if (!accToolCalls.has(tc.index)) {
172
+ accToolCalls.set(tc.index, { id: "", name: "", args: "" })
173
+ }
174
+ const entry = accToolCalls.get(tc.index)!
175
+ if (tc.id) entry.id = tc.id
176
+ if (tc.function?.name) entry.name = tc.function.name
177
+ if (tc.function?.arguments) entry.args += tc.function.arguments
178
+ }
179
+ }
180
+
181
+ if (chunk.choices[0]?.finish_reason) {
182
+ const finishReason = chunk.choices[0].finish_reason
183
+ const toolCalls = Array.from(accToolCalls.values())
184
+ .filter((tc) => tc.id && tc.name)
185
+ .map((tc) => ({
186
+ id: tc.id,
187
+ name: tc.name,
188
+ args: JSON.parse(tc.args || "{}"),
189
+ }))
190
+
191
+ yield {
192
+ type: "finish",
193
+ content: fullContent,
194
+ toolCalls,
195
+ usage: {
196
+ promptTokens: chunk.usage?.prompt_tokens || 0,
197
+ completionTokens: chunk.usage?.completion_tokens || 0,
198
+ totalTokens: chunk.usage?.total_tokens || 0,
199
+ },
200
+ model,
201
+ finishReason,
202
+ }
203
+ }
204
+ }
205
+
206
+ await globalEventBus.emit("provider:complete", {
207
+ provider: this.name,
208
+ model,
209
+ tokens: 0,
210
+ latencyMs: Date.now() - start,
211
+ } as any)
212
+ } catch (err: any) {
213
+ await globalEventBus.emit("provider:error", {
214
+ provider: this.name,
215
+ model,
216
+ error: err.message,
217
+ } as any)
218
+ throw new ProviderError(this.name, err.message, { model, latencyMs: Date.now() - start })
219
+ }
220
+ }
221
+
222
+ async getModels(): Promise<string[]> {
223
+ try {
224
+ const response = await fetch("https://api.groq.com/openai/v1/models", {
225
+ headers: {
226
+ Authorization: `Bearer ${getGroqKey()}`,
227
+ },
228
+ })
229
+ const data: any = await response.json()
230
+ return data.data?.map((m: any) => m.id) || []
231
+ } catch {
232
+ return [this.defaultModel]
233
+ }
234
+ }
235
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { providerRegistry } from "./registry"
2
+ import { OpenRouterProvider } from "./openrouter"
3
+ import { GoogleProvider } from "./google"
4
+ import { GroqProvider } from "./groq"
5
+
6
+ providerRegistry.register("openrouter", OpenRouterProvider)
7
+ providerRegistry.register("google", GoogleProvider)
8
+ providerRegistry.register("groq", GroqProvider)
9
+
10
+ export { providerRegistry }
11
+ export type { LLMProvider } from "./interface"
12
+ export { OpenRouterProvider } from "./openrouter"
13
+ export { GoogleProvider } from "./google"
14
+ export { GroqProvider } from "./groq"
@@ -0,0 +1,12 @@
1
+ import type { CompletionRequest, CompletionResponse, StreamChunk, ProviderConfig } from "@lupaflow/types"
2
+
3
+ export interface LLMProvider {
4
+ readonly name: string
5
+ readonly defaultModel: string
6
+ config: ProviderConfig
7
+
8
+ complete(request: CompletionRequest): Promise<CompletionResponse>
9
+ streamComplete(request: CompletionRequest): AsyncIterable<StreamChunk>
10
+ getModels?(): Promise<string[]>
11
+ validateConfig(): boolean
12
+ }
@@ -0,0 +1,235 @@
1
+ import OpenAI from "openai"
2
+ import type { LLMProvider } from "./interface"
3
+ import type { CompletionRequest, CompletionResponse, StreamChunk, ProviderConfig } from "@lupaflow/types"
4
+ import { ProviderError } from "@lupaflow/core"
5
+ import { getOpenRouterKey } from "@lupaflow/secrets"
6
+ import { globalEventBus } from "@lupaflow/core"
7
+
8
+ export class OpenRouterProvider implements LLMProvider {
9
+ readonly name = "openrouter"
10
+ readonly defaultModel = "openai/gpt-4o-mini"
11
+ config: ProviderConfig
12
+
13
+ private client: OpenAI
14
+
15
+ constructor(config: ProviderConfig = {}) {
16
+ this.config = config
17
+ this.client = new OpenAI({
18
+ apiKey: config.apiKey || getOpenRouterKey(),
19
+ baseURL: config.baseUrl || "https://openrouter.ai/api/v1",
20
+ defaultHeaders: {
21
+ "HTTP-Referer": "https://lupaflow.dev",
22
+ "X-Title": "LupaFlow",
23
+ },
24
+ })
25
+ }
26
+
27
+ validateConfig(): boolean {
28
+ try {
29
+ getOpenRouterKey()
30
+ return true
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
36
+ private buildMessages(request: CompletionRequest): OpenAI.Chat.ChatCompletionMessageParam[] {
37
+ const messages: OpenAI.Chat.ChatCompletionMessageParam[] = []
38
+ if (request.systemPrompt) {
39
+ messages.push({ role: "system", content: request.systemPrompt })
40
+ }
41
+ for (const m of request.messages) {
42
+ if (m.role === "assistant" && m.toolCalls?.length) {
43
+ messages.push({
44
+ role: "assistant",
45
+ content: m.content as string | null,
46
+ tool_calls: m.toolCalls as any,
47
+ })
48
+ } else if (m.role === "tool") {
49
+ messages.push({
50
+ role: "tool",
51
+ tool_call_id: m.toolCallId || "",
52
+ content: m.content || "",
53
+ })
54
+ } else {
55
+ messages.push({ role: m.role as any, content: m.content })
56
+ }
57
+ }
58
+ return messages
59
+ }
60
+
61
+ private buildTools(
62
+ tools?: CompletionRequest["tools"]
63
+ ): OpenAI.Chat.ChatCompletionTool[] | undefined {
64
+ return tools?.map((t) => ({
65
+ type: "function" as const,
66
+ function: {
67
+ name: t.definition.name,
68
+ description: t.definition.description,
69
+ parameters: {
70
+ type: "object",
71
+ properties: t.definition.parameters,
72
+ required: t.definition.required,
73
+ },
74
+ },
75
+ }))
76
+ }
77
+
78
+ async complete(request: CompletionRequest): Promise<CompletionResponse> {
79
+ const start = Date.now()
80
+ const model = request.model || this.config.model || this.defaultModel
81
+
82
+ await globalEventBus.emit("provider:start", {
83
+ provider: this.name,
84
+ model,
85
+ } as any)
86
+
87
+ try {
88
+ const messages = this.buildMessages(request)
89
+ const tools = this.buildTools(request.tools)
90
+
91
+ const response = await this.client.chat.completions.create({
92
+ model,
93
+ messages,
94
+ tools,
95
+ temperature: request.temperature ?? this.config.temperature,
96
+ max_tokens: request.maxTokens ?? this.config.maxTokens,
97
+ top_p: request.topP ?? this.config.topP,
98
+ })
99
+
100
+ const choice = response.choices[0]
101
+ const latencyMs = Date.now() - start
102
+
103
+ const result: CompletionResponse = {
104
+ content: choice.message.content,
105
+ toolCalls: (choice.message.tool_calls || []).map((tc) => ({
106
+ id: tc.id,
107
+ name: tc.function.name,
108
+ args: JSON.parse(tc.function.arguments),
109
+ })),
110
+ usage: {
111
+ promptTokens: response.usage?.prompt_tokens || 0,
112
+ completionTokens: response.usage?.completion_tokens || 0,
113
+ totalTokens: response.usage?.total_tokens || 0,
114
+ },
115
+ model: response.model,
116
+ latencyMs,
117
+ finishReason: choice.finish_reason || "stop",
118
+ }
119
+
120
+ await globalEventBus.emit("provider:complete", {
121
+ provider: this.name,
122
+ model,
123
+ tokens: result.usage.totalTokens,
124
+ latencyMs,
125
+ } as any)
126
+
127
+ return result
128
+ } catch (err: any) {
129
+ const latencyMs = Date.now() - start
130
+ await globalEventBus.emit("provider:error", {
131
+ provider: this.name,
132
+ model,
133
+ error: err.message,
134
+ } as any)
135
+ throw new ProviderError(this.name, err.message, { model, latencyMs })
136
+ }
137
+ }
138
+
139
+ async *streamComplete(request: CompletionRequest): AsyncIterable<StreamChunk> {
140
+ const start = Date.now()
141
+ const model = request.model || this.config.model || this.defaultModel
142
+
143
+ await globalEventBus.emit("provider:start", {
144
+ provider: this.name,
145
+ model,
146
+ } as any)
147
+
148
+ try {
149
+ const messages = this.buildMessages(request)
150
+ const tools = this.buildTools(request.tools)
151
+
152
+ const stream = await this.client.chat.completions.create({
153
+ model,
154
+ messages,
155
+ tools,
156
+ temperature: request.temperature ?? this.config.temperature,
157
+ max_tokens: request.maxTokens ?? this.config.maxTokens,
158
+ top_p: request.topP ?? this.config.topP,
159
+ stream: true,
160
+ })
161
+
162
+ let fullContent = ""
163
+ const accToolCalls = new Map<number, { id: string; name: string; args: string }>()
164
+
165
+ for await (const chunk of stream) {
166
+ const delta = chunk.choices[0]?.delta
167
+
168
+ if (delta?.content) {
169
+ fullContent += delta.content
170
+ yield { type: "text", text: delta.content }
171
+ }
172
+
173
+ if (delta?.tool_calls) {
174
+ for (const tc of delta.tool_calls) {
175
+ if (!accToolCalls.has(tc.index)) {
176
+ accToolCalls.set(tc.index, { id: "", name: "", args: "" })
177
+ }
178
+ const entry = accToolCalls.get(tc.index)!
179
+ if (tc.id) entry.id = tc.id
180
+ if (tc.function?.name) entry.name = tc.function.name
181
+ if (tc.function?.arguments) entry.args += tc.function.arguments
182
+ }
183
+ }
184
+
185
+ if (chunk.choices[0]?.finish_reason) {
186
+ const finishReason = chunk.choices[0].finish_reason
187
+ const toolCalls = Array.from(accToolCalls.values())
188
+ .filter((tc) => tc.id && tc.name)
189
+ .map((tc) => ({
190
+ id: tc.id,
191
+ name: tc.name,
192
+ args: JSON.parse(tc.args || "{}"),
193
+ }))
194
+
195
+ yield {
196
+ type: "finish",
197
+ content: fullContent,
198
+ toolCalls,
199
+ usage: {
200
+ promptTokens: chunk.usage?.prompt_tokens || 0,
201
+ completionTokens: chunk.usage?.completion_tokens || 0,
202
+ totalTokens: chunk.usage?.total_tokens || 0,
203
+ },
204
+ model,
205
+ finishReason,
206
+ }
207
+ }
208
+ }
209
+
210
+ await globalEventBus.emit("provider:complete", {
211
+ provider: this.name,
212
+ model,
213
+ tokens: 0,
214
+ latencyMs: Date.now() - start,
215
+ } as any)
216
+ } catch (err: any) {
217
+ await globalEventBus.emit("provider:error", {
218
+ provider: this.name,
219
+ model,
220
+ error: err.message,
221
+ } as any)
222
+ throw new ProviderError(this.name, err.message, { model, latencyMs: Date.now() - start })
223
+ }
224
+ }
225
+
226
+ async getModels(): Promise<string[]> {
227
+ try {
228
+ const response = await fetch("https://openrouter.ai/api/v1/models")
229
+ const data: any = await response.json()
230
+ return data.data?.map((m: any) => m.id) || []
231
+ } catch {
232
+ return [this.defaultModel]
233
+ }
234
+ }
235
+ }
@@ -0,0 +1,50 @@
1
+ import type { ProviderName, ProviderConfig, CompletionRequest, CompletionResponse, StreamChunk } from "@lupaflow/types"
2
+ import { ConfigError } from "@lupaflow/core"
3
+
4
+ interface LLMProvider {
5
+ readonly name: string
6
+ readonly defaultModel: string
7
+ config: ProviderConfig
8
+ complete(request: CompletionRequest): Promise<CompletionResponse>
9
+ streamComplete(request: CompletionRequest): AsyncIterable<StreamChunk>
10
+ getModels?(): Promise<string[]>
11
+ validateConfig(): boolean
12
+ }
13
+
14
+ class ProviderRegistry {
15
+ private providers: Map<ProviderName, new (config: ProviderConfig) => LLMProvider> = new Map()
16
+ private instances: Map<ProviderName, LLMProvider> = new Map()
17
+
18
+ register(name: ProviderName, ctor: new (config: ProviderConfig) => LLMProvider): void {
19
+ this.providers.set(name, ctor)
20
+ }
21
+
22
+ get(name: ProviderName, config?: ProviderConfig): LLMProvider {
23
+ if (!config && this.instances.has(name)) {
24
+ return this.instances.get(name)!
25
+ }
26
+
27
+ const ctor = this.providers.get(name)
28
+ if (!ctor) {
29
+ throw new ConfigError(`Unknown provider: "${name}"`, {
30
+ available: Array.from(this.providers.keys()),
31
+ })
32
+ }
33
+
34
+ const instance = new ctor(config || {})
35
+ if (!config) {
36
+ this.instances.set(name, instance)
37
+ }
38
+ return instance
39
+ }
40
+
41
+ has(name: string): name is ProviderName {
42
+ return this.providers.has(name as ProviderName)
43
+ }
44
+
45
+ list(): ProviderName[] {
46
+ return Array.from(this.providers.keys())
47
+ }
48
+ }
49
+
50
+ export const providerRegistry = new ProviderRegistry()
package/tsconfig.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src"]
8
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "tsup"
2
+ export default defineConfig({
3
+ entry: { index: "src/index.ts" },
4
+ format: ["cjs", "esm"],
5
+ dts: true,
6
+ sourcemap: true,
7
+ clean: true,
8
+ external: [/node_modules/],
9
+ })