Skip to content

Commit 86401fa

Browse files
committed
Better encapsulation for API config
1 parent c3da5b0 commit 86401fa

File tree

4 files changed

+222
-59
lines changed

4 files changed

+222
-59
lines changed

src/core/__tests__/contextProxy.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,20 @@ import * as vscode from "vscode"
22
import { ContextProxy } from "../contextProxy"
33
import { logger } from "../../utils/logging"
44
import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../../shared/globalState"
5+
import { ApiConfiguration } from "../../shared/api"
56

67
// Mock shared/globalState
78
jest.mock("../../shared/globalState", () => ({
89
GLOBAL_STATE_KEYS: ["apiProvider", "apiModelId", "mode"],
910
SECRET_KEYS: ["apiKey", "openAiApiKey"],
11+
GlobalStateKey: {},
12+
SecretKey: {},
13+
}))
14+
15+
// Mock shared/api
16+
jest.mock("../../shared/api", () => ({
17+
API_CONFIG_KEYS: ["apiProvider", "apiModelId"],
18+
ApiConfiguration: {},
1019
}))
1120

1221
// Mock VSCode API
@@ -153,5 +162,82 @@ describe("ContextProxy", () => {
153162
const storedValue = await proxy.getSecret("api-key")
154163
expect(storedValue).toBeUndefined()
155164
})
165+
166+
describe("getApiConfiguration", () => {
167+
it("should combine global state and secrets into a single ApiConfiguration object", async () => {
168+
// Mock data in state cache
169+
await proxy.updateGlobalState("apiProvider", "anthropic")
170+
await proxy.updateGlobalState("apiModelId", "test-model")
171+
// Mock data in secrets cache
172+
await proxy.storeSecret("apiKey", "test-api-key")
173+
174+
const config = proxy.getApiConfiguration()
175+
176+
// Should contain values from global state
177+
expect(config.apiProvider).toBe("anthropic")
178+
expect(config.apiModelId).toBe("test-model")
179+
// Should contain values from secrets
180+
expect(config.apiKey).toBe("test-api-key")
181+
})
182+
183+
it("should handle special case for apiProvider defaulting", async () => {
184+
// Clear apiProvider but set apiKey
185+
await proxy.updateGlobalState("apiProvider", undefined)
186+
await proxy.storeSecret("apiKey", "test-api-key")
187+
188+
const config = proxy.getApiConfiguration()
189+
190+
// Should default to anthropic when apiKey exists
191+
expect(config.apiProvider).toBe("anthropic")
192+
193+
// Clear both apiProvider and apiKey
194+
await proxy.updateGlobalState("apiProvider", undefined)
195+
await proxy.storeSecret("apiKey", undefined)
196+
197+
const configWithoutKey = proxy.getApiConfiguration()
198+
199+
// Should default to openrouter when no apiKey exists
200+
expect(configWithoutKey.apiProvider).toBe("openrouter")
201+
})
202+
})
203+
204+
describe("updateApiConfiguration", () => {
205+
it("should update both global state and secrets", async () => {
206+
const apiConfig: ApiConfiguration = {
207+
apiProvider: "anthropic",
208+
apiModelId: "claude-latest",
209+
apiKey: "test-api-key",
210+
}
211+
212+
await proxy.updateApiConfiguration(apiConfig)
213+
214+
// Should update global state
215+
expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "anthropic")
216+
expect(mockGlobalState.update).toHaveBeenCalledWith("apiModelId", "claude-latest")
217+
// Should update secrets
218+
expect(mockSecrets.store).toHaveBeenCalledWith("apiKey", "test-api-key")
219+
220+
// Check that values are in cache
221+
expect(proxy.getGlobalState("apiProvider")).toBe("anthropic")
222+
expect(proxy.getGlobalState("apiModelId")).toBe("claude-latest")
223+
expect(proxy.getSecret("apiKey")).toBe("test-api-key")
224+
})
225+
226+
it("should ignore keys that aren't in either GLOBAL_STATE_KEYS or SECRET_KEYS", async () => {
227+
// Use type assertion to add an invalid key
228+
const apiConfig = {
229+
apiProvider: "anthropic",
230+
invalidKey: "should be ignored",
231+
} as ApiConfiguration & { invalidKey: string }
232+
233+
await proxy.updateApiConfiguration(apiConfig)
234+
235+
// Should update keys in GLOBAL_STATE_KEYS
236+
expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "anthropic")
237+
// Should not call update/store for invalid keys
238+
expect(mockGlobalState.update).not.toHaveBeenCalledWith("invalidKey", expect.anything())
239+
expect(mockSecrets.store).not.toHaveBeenCalledWith("invalidKey", expect.anything())
240+
})
241+
})
156242
})
157243
})

src/core/contextProxy.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as vscode from "vscode"
22
import { logger } from "../utils/logging"
3-
import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../shared/globalState"
3+
import { ApiConfiguration, API_CONFIG_KEYS } from "../shared/api"
4+
import { GLOBAL_STATE_KEYS, SECRET_KEYS, GlobalStateKey, SecretKey } from "../shared/globalState"
45

56
export class ContextProxy {
67
private readonly originalContext: vscode.ExtensionContext
@@ -82,7 +83,6 @@ export class ContextProxy {
8283
getSecret(key: string): string | undefined {
8384
return this.secretCache.get(key)
8485
}
85-
8686
storeSecret(key: string, value?: string): Thenable<void> {
8787
// Update cache
8888
this.secretCache.set(key, value)
@@ -93,4 +93,62 @@ export class ContextProxy {
9393
return this.originalContext.secrets.store(key, value)
9494
}
9595
}
96+
97+
/**
98+
* Gets a complete ApiConfiguration object by fetching values
99+
* from both global state and secrets storage
100+
*/
101+
getApiConfiguration(): ApiConfiguration {
102+
// Create an empty ApiConfiguration object
103+
const config: ApiConfiguration = {}
104+
105+
// Add all API-related keys from global state
106+
for (const key of API_CONFIG_KEYS) {
107+
const value = this.getGlobalState(key)
108+
if (value !== undefined) {
109+
// Use type assertion to avoid TypeScript error
110+
;(config as any)[key] = value
111+
}
112+
}
113+
114+
// Add all secret values
115+
for (const key of SECRET_KEYS) {
116+
const value = this.getSecret(key)
117+
if (value !== undefined) {
118+
// Use type assertion to avoid TypeScript error
119+
;(config as any)[key] = value
120+
}
121+
}
122+
123+
// Handle special case for apiProvider if needed (same logic as current implementation)
124+
if (!config.apiProvider) {
125+
if (config.apiKey) {
126+
config.apiProvider = "anthropic"
127+
} else {
128+
config.apiProvider = "openrouter"
129+
}
130+
}
131+
132+
return config
133+
}
134+
135+
/**
136+
* Updates an ApiConfiguration by persisting each property
137+
* to the appropriate storage (global state or secrets)
138+
*/
139+
async updateApiConfiguration(apiConfiguration: ApiConfiguration): Promise<void> {
140+
const promises: Array<Thenable<void>> = []
141+
142+
// For each property, update the appropriate storage
143+
Object.entries(apiConfiguration).forEach(([key, value]) => {
144+
if (SECRET_KEYS.includes(key as SecretKey)) {
145+
promises.push(this.storeSecret(key, value))
146+
} else if (API_CONFIG_KEYS.includes(key as GlobalStateKey)) {
147+
promises.push(this.updateGlobalState(key, value))
148+
}
149+
// Ignore keys that aren't in either list
150+
})
151+
152+
await Promise.all(promises)
153+
}
96154
}

src/core/webview/ClineProvider.ts

Lines changed: 15 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1659,20 +1659,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
16591659
}
16601660
}
16611661

1662-
// Create an array of promises to update state
1663-
const promises: Promise<any>[] = []
1664-
1665-
// For each property in apiConfiguration, update the appropriate state
1666-
Object.entries(apiConfiguration).forEach(([key, value]) => {
1667-
// Check if this key is a secret
1668-
if (SECRET_KEYS.includes(key as SecretKey)) {
1669-
promises.push(this.storeSecret(key as SecretKey, value))
1670-
} else {
1671-
promises.push(this.updateGlobalState(key as GlobalStateKey, value))
1672-
}
1673-
})
1674-
1675-
await Promise.all(promises)
1662+
// Update all configuration values through the contextProxy
1663+
await this.contextProxy.updateApiConfiguration(apiConfiguration)
16761664

16771665
if (this.cline) {
16781666
this.cline.api = buildApiHandler(apiConfiguration)
@@ -2073,62 +2061,32 @@ export class ClineProvider implements vscode.WebviewViewProvider {
20732061
*/
20742062

20752063
async getState() {
2076-
// Create an object to store all fetched values
2077-
const stateValues: Record<GlobalStateKey | SecretKey, any> = {} as Record<GlobalStateKey | SecretKey, any>
2078-
const secretValues: Record<SecretKey, any> = {} as Record<SecretKey, any>
2064+
// Get ApiConfiguration directly from contextProxy
2065+
const apiConfiguration = this.contextProxy.getApiConfiguration()
2066+
2067+
// Create an object to store all fetched values (excluding API config which we already have)
2068+
const stateValues: Record<GlobalStateKey, any> = {} as Record<GlobalStateKey, any>
20792069

2080-
// Create promise arrays for global state and secrets
2081-
const statePromises = GLOBAL_STATE_KEYS.map((key) => this.getGlobalState(key))
2082-
const secretPromises = SECRET_KEYS.map((key) => this.getSecret(key))
2070+
// Create promise arrays for global state
2071+
const statePromises = GLOBAL_STATE_KEYS
2072+
// Filter out API config keys since we already have them
2073+
.filter((key) => !API_CONFIG_KEYS.includes(key))
2074+
.map((key) => this.getGlobalState(key))
20832075

20842076
// Add promise for custom modes which is handled separately
20852077
const customModesPromise = this.customModesManager.getCustomModes()
20862078

20872079
let idx = 0
2088-
const valuePromises = await Promise.all([...statePromises, ...secretPromises, customModesPromise])
2080+
const valuePromises = await Promise.all([...statePromises, customModesPromise])
20892081

2090-
// Populate stateValues and secretValues
2091-
GLOBAL_STATE_KEYS.forEach((key, _) => {
2082+
// Populate stateValues
2083+
GLOBAL_STATE_KEYS.filter((key) => !API_CONFIG_KEYS.includes(key)).forEach((key) => {
20922084
stateValues[key] = valuePromises[idx]
20932085
idx = idx + 1
20942086
})
20952087

2096-
SECRET_KEYS.forEach((key, index) => {
2097-
secretValues[key] = valuePromises[idx]
2098-
idx = idx + 1
2099-
})
2100-
21012088
let customModes = valuePromises[idx] as ModeConfig[] | undefined
21022089

2103-
// Determine apiProvider with the same logic as before
2104-
let apiProvider: ApiProvider
2105-
if (stateValues.apiProvider) {
2106-
apiProvider = stateValues.apiProvider
2107-
} else {
2108-
// Either new user or legacy user that doesn't have the apiProvider stored in state
2109-
// (If they're using OpenRouter or Bedrock, then apiProvider state will exist)
2110-
if (secretValues.apiKey) {
2111-
apiProvider = "anthropic"
2112-
} else {
2113-
// New users should default to openrouter
2114-
apiProvider = "openrouter"
2115-
}
2116-
}
2117-
2118-
// Build the apiConfiguration object combining state values and secrets
2119-
// Using the dynamic approach with API_CONFIG_KEYS
2120-
const apiConfiguration: ApiConfiguration = {
2121-
// Dynamically add all API-related keys from stateValues
2122-
...Object.fromEntries(API_CONFIG_KEYS.map((key) => [key, stateValues[key]])),
2123-
// Add all secrets
2124-
...secretValues,
2125-
}
2126-
2127-
// Ensure apiProvider is set properly if not already in state
2128-
if (!apiConfiguration.apiProvider) {
2129-
apiConfiguration.apiProvider = apiProvider
2130-
}
2131-
21322090
// Return the same structure as before
21332091
return {
21342092
apiConfiguration,

src/core/webview/__tests__/ClineProvider.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,21 @@ jest.mock("../../contextProxy", () => {
3434
.mockImplementation((key, value) =>
3535
value ? context.secrets.store(key, value) : context.secrets.delete(key),
3636
),
37+
getApiConfiguration: jest.fn().mockImplementation(() => ({
38+
apiProvider: "openrouter",
39+
// Add other common properties
40+
})),
41+
updateApiConfiguration: jest.fn().mockImplementation(async (apiConfiguration) => {
42+
// Mock implementation that simulates updating state and secrets
43+
for (const [key, value] of Object.entries(apiConfiguration)) {
44+
if (key === "apiKey" || key === "openAiApiKey") {
45+
context.secrets.store(key, value)
46+
} else {
47+
context.globalState.update(key, value)
48+
}
49+
}
50+
return Promise.resolve()
51+
}),
3752
saveChanges: jest.fn().mockResolvedValue(undefined),
3853
dispose: jest.fn().mockResolvedValue(undefined),
3954
hasPendingChanges: jest.fn().mockReturnValue(false),
@@ -1579,5 +1594,51 @@ describe("ContextProxy integration", () => {
15791594
expect(mockContextProxy.getGlobalState).toBeDefined()
15801595
expect(mockContextProxy.updateGlobalState).toBeDefined()
15811596
expect(mockContextProxy.storeSecret).toBeDefined()
1597+
expect(mockContextProxy.getApiConfiguration).toBeDefined()
1598+
expect(mockContextProxy.updateApiConfiguration).toBeDefined()
1599+
})
1600+
1601+
test("getState uses contextProxy.getApiConfiguration", async () => {
1602+
// Setup mock API configuration
1603+
const mockApiConfig = {
1604+
apiProvider: "anthropic",
1605+
apiModelId: "claude-latest",
1606+
apiKey: "test-api-key",
1607+
}
1608+
mockContextProxy.getApiConfiguration.mockReturnValue(mockApiConfig)
1609+
1610+
// Get state
1611+
const state = await provider.getState()
1612+
1613+
// Verify getApiConfiguration was called
1614+
expect(mockContextProxy.getApiConfiguration).toHaveBeenCalled()
1615+
// Verify state has the API configuration from contextProxy
1616+
expect(state.apiConfiguration).toBe(mockApiConfig)
1617+
})
1618+
1619+
test("updateApiConfiguration uses contextProxy.updateApiConfiguration", async () => {
1620+
// Setup test config
1621+
const testApiConfig = {
1622+
apiProvider: "anthropic",
1623+
apiModelId: "claude-latest",
1624+
apiKey: "test-api-key",
1625+
}
1626+
1627+
// Mock methods needed for the test
1628+
provider.configManager = {
1629+
listConfig: jest.fn().mockResolvedValue([]),
1630+
setModeConfig: jest.fn(),
1631+
} as any
1632+
1633+
// Mock getState for mode
1634+
jest.spyOn(provider, "getState").mockResolvedValue({
1635+
mode: "code",
1636+
} as any)
1637+
1638+
// Call the private method - need to use any to access it
1639+
await (provider as any).updateApiConfiguration(testApiConfig)
1640+
1641+
// Verify contextProxy.updateApiConfiguration was called with the right config
1642+
expect(mockContextProxy.updateApiConfiguration).toHaveBeenCalledWith(testApiConfig)
15821643
})
15831644
})

0 commit comments

Comments
 (0)