Skip to content

Commit 9d2b0a3

Browse files
committed
feat(core): Add custom providers service implementation
- Add CustomProvidersFileService for managing provider configurations - Implement secure API key storage using VS Code's SecretStorage - Add validation schema for custom provider configurations - Add file-based persistence for provider settings - Support provider CRUD operations with API key management
1 parent 5d78b4c commit 9d2b0a3

File tree

2 files changed

+252
-0
lines changed

2 files changed

+252
-0
lines changed
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import * as vscode from "vscode"
2+
import * as fs from "fs/promises"
3+
import * as path from "path"
4+
import { ClineProvidersConfig, CustomProviderConfig } from "../../shared/api"
5+
import { GlobalFileNames } from "../../core/webview/ClineProvider"
6+
import { customProviderConfigSchema } from "./CustomProvidersSchema"
7+
8+
export class CustomProvidersFileService {
9+
public readonly providersFilePath: string
10+
private readonly secrets: vscode.SecretStorage
11+
12+
constructor(storagePath: string, secrets: vscode.SecretStorage) {
13+
this.providersFilePath = path.join(storagePath, GlobalFileNames.customProviders)
14+
this.secrets = secrets
15+
}
16+
17+
/**
18+
* Add a new custom provider with validation
19+
*/
20+
public async addProvider(provider: CustomProviderConfig): Promise<void> {
21+
try {
22+
const secretKey = `${provider.name}_API_KEY`
23+
24+
// Store API key in secrets
25+
if (!provider.apiKey) {
26+
throw new Error("API key is required")
27+
}
28+
29+
console.log(`Storing API key for provider ${provider.name}`)
30+
await this.secrets.store(secretKey, provider.apiKey)
31+
32+
// Verify the key was stored
33+
const storedKey = await this.secrets.get(secretKey)
34+
if (!storedKey) {
35+
throw new Error(`Failed to store API key for provider ${provider.name}`)
36+
}
37+
38+
// Save provider config without the actual API key
39+
const providerConfig = {
40+
...provider,
41+
apiKey: `\${${secretKey}}`, // Store placeholder
42+
request: {
43+
...provider.request,
44+
headers: {
45+
...provider.request.headers,
46+
Authorization: `Bearer \${${secretKey}}`,
47+
},
48+
},
49+
}
50+
51+
const providers = await this.readProviders()
52+
providers[provider.name] = providerConfig
53+
await this.writeProviders(providers)
54+
} catch (error) {
55+
console.error("Error adding provider:", error)
56+
throw error
57+
}
58+
}
59+
60+
/**
61+
* Delete a custom provider and its associated API key
62+
*/
63+
public async deleteProvider(name: string): Promise<void> {
64+
try {
65+
const providers = await this.readProviders()
66+
delete providers[name]
67+
await this.secrets.delete(`${name}_API_KEY`)
68+
await this.writeProviders(providers)
69+
} catch (error) {
70+
console.error("Error deleting provider:", error)
71+
throw new Error(`Failed to delete provider: ${error instanceof Error ? error.message : String(error)}`)
72+
}
73+
}
74+
75+
/**
76+
* Get all providers with their API keys from secrets storage
77+
*/
78+
public async getProviders(): Promise<Record<string, CustomProviderConfig>> {
79+
try {
80+
const providers = await this.readProviders()
81+
const providersWithKeys: Record<string, CustomProviderConfig> = {}
82+
83+
for (const [name, provider] of Object.entries(providers)) {
84+
const secretKey = `${name}_API_KEY`
85+
const apiKey = await this.secrets.get(secretKey)
86+
87+
if (!apiKey) {
88+
console.warn(`No API key found in secrets for provider: ${name}`)
89+
continue // Skip providers without API keys
90+
}
91+
92+
providersWithKeys[name] = {
93+
...provider,
94+
apiKey,
95+
request: {
96+
...provider.request,
97+
headers: {
98+
...provider.request.headers,
99+
Authorization: `Bearer ${apiKey}`,
100+
},
101+
},
102+
}
103+
}
104+
105+
return providersWithKeys
106+
} catch (error) {
107+
console.error("Error getting providers:", error)
108+
throw error
109+
}
110+
}
111+
112+
/**
113+
* Read providers from the configuration file
114+
*/
115+
public async readProviders(): Promise<Record<string, CustomProviderConfig>> {
116+
try {
117+
const content = await fs.readFile(this.providersFilePath, "utf-8")
118+
const config: ClineProvidersConfig = JSON.parse(content)
119+
return config.providers
120+
} catch (error) {
121+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
122+
return {}
123+
}
124+
console.error("Error reading providers:", error)
125+
throw new Error(`Failed to read providers: ${error instanceof Error ? error.message : String(error)}`)
126+
}
127+
}
128+
129+
/**
130+
* Write providers to the configuration file
131+
*/
132+
public async writeProviders(providers: Record<string, CustomProviderConfig>): Promise<void> {
133+
try {
134+
const config: ClineProvidersConfig = { providers }
135+
const content = JSON.stringify(config, null, 2)
136+
console.log(`Writing providers to file: ${this.providersFilePath}`) // Added log
137+
console.log(`Content: ${content}`) // Added log
138+
await fs.writeFile(this.providersFilePath, content, "utf-8")
139+
console.log("Providers written successfully.") // Added log
140+
} catch (error) {
141+
console.error("Error writing providers:", error)
142+
throw new Error(`Failed to write providers: ${error instanceof Error ? error.message : String(error)}`)
143+
}
144+
}
145+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { z } from "zod"
2+
3+
// Default configurations
4+
export const defaultRequestConfig = {
5+
url: "http://localhost:1234/v1/chat/completions",
6+
method: "POST" as const,
7+
headers: {
8+
"Content-Type": "application/json",
9+
},
10+
}
11+
12+
export const defaultFormatConfig = {
13+
method: "POST" as const,
14+
messages: "array" as const,
15+
data: '{"temperature":0.7,"stream":true, "model":"gpt-4o"}',
16+
}
17+
18+
export const defaultProviderVariables = {
19+
temperature: 0.7,
20+
stream: true,
21+
model: "gpt-3.5-turbo",
22+
maxOutputTokens: 4096,
23+
}
24+
25+
export const defaultProviderConfig = {
26+
maxTokens: 4096,
27+
contextWindow: 8192,
28+
supportsImages: false,
29+
supportsComputerUse: false,
30+
request: defaultRequestConfig,
31+
format: defaultFormatConfig,
32+
responsePath: "choices[0].message.content",
33+
variables: defaultProviderVariables,
34+
}
35+
36+
export const providerVariablesSchema = z.object({
37+
temperature: z
38+
.number()
39+
.min(0)
40+
.max(2)
41+
.default(defaultProviderVariables.temperature)
42+
.describe(
43+
"Temperature controls randomness in the response. Higher values make the output more random, lower values make it more focused and deterministic.",
44+
),
45+
stream: z
46+
.boolean()
47+
.default(defaultProviderVariables.stream)
48+
.describe("Whether to stream the response as it's generated."),
49+
model: z.string().default(defaultProviderVariables.model).describe("The model to use for generating responses."),
50+
maxOutputTokens: z
51+
.number()
52+
.int()
53+
.positive()
54+
.default(defaultProviderVariables.maxOutputTokens)
55+
.describe("Maximum number of tokens in the response."),
56+
})
57+
58+
// Zod schemas
59+
export const requestConfigSchema = z.object({
60+
url: z.string().url().default(defaultRequestConfig.url),
61+
method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).default(defaultRequestConfig.method),
62+
headers: z.record(z.string()).default(defaultRequestConfig.headers),
63+
data: z.any().optional(),
64+
})
65+
66+
export const formatConfigSchema = z.object({
67+
method: z.enum(["POST"]).default(defaultFormatConfig.method),
68+
messages: z.enum(["array", "string"]).default(defaultFormatConfig.messages),
69+
data: z.string().default(defaultFormatConfig.data),
70+
})
71+
72+
export const customVariableSchema = z.object({
73+
name: z.string(),
74+
value: z.string(),
75+
description: z.string().optional(),
76+
})
77+
78+
export const customProviderConfigSchema = z
79+
.object({
80+
id: z.string(),
81+
name: z.string(),
82+
model: z.string().optional(),
83+
maxTokens: z.number().int().positive().default(defaultProviderConfig.maxTokens),
84+
contextWindow: z.number().int().positive().default(defaultProviderConfig.contextWindow),
85+
supportsImages: z.boolean().default(defaultProviderConfig.supportsImages),
86+
supportsComputerUse: z.boolean().default(defaultProviderConfig.supportsComputerUse),
87+
request: requestConfigSchema.default(defaultRequestConfig),
88+
format: formatConfigSchema.default(defaultFormatConfig),
89+
responsePath: z.string().default(defaultProviderConfig.responsePath),
90+
description: z.string().optional(),
91+
customVariables: z.record(customVariableSchema).optional(),
92+
apiKey: z.string().optional(),
93+
variables: providerVariablesSchema
94+
.default(defaultProviderVariables)
95+
.describe("Common configuration variables that will be substituted in the request format."),
96+
})
97+
.default({
98+
...defaultProviderConfig,
99+
id: "",
100+
name: "",
101+
})
102+
103+
export const customProvidersConfigSchema = z.object({
104+
providers: z.record(customProviderConfigSchema),
105+
})
106+
107+
export type CustomProvidersSchemaType = z.infer<typeof customProvidersConfigSchema>

0 commit comments

Comments
 (0)