Skip to content

Commit fffc9f7

Browse files
authored
Merge pull request #848 from websentry-ai/pm/unbound-fetch-models
Fetches Unbound models from api
2 parents 47bdab0 + 5995923 commit fffc9f7

File tree

11 files changed

+175
-41
lines changed

11 files changed

+175
-41
lines changed

src/api/providers/__tests__/unbound.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ describe("UnboundHandler", () => {
7373
mockOptions = {
7474
apiModelId: "anthropic/claude-3-5-sonnet-20241022",
7575
unboundApiKey: "test-api-key",
76+
unboundModelId: "anthropic/claude-3-5-sonnet-20241022",
77+
unboundModelInfo: {
78+
description: "Anthropic's Claude 3 Sonnet model",
79+
maxTokens: 8192,
80+
contextWindow: 200000,
81+
supportsPromptCache: true,
82+
inputPrice: 0.01,
83+
outputPrice: 0.02,
84+
},
7685
}
7786
handler = new UnboundHandler(mockOptions)
7887
mockCreate.mockClear()
@@ -205,6 +214,15 @@ describe("UnboundHandler", () => {
205214
const nonAnthropicOptions = {
206215
apiModelId: "openai/gpt-4o",
207216
unboundApiKey: "test-key",
217+
unboundModelId: "openai/gpt-4o",
218+
unboundModelInfo: {
219+
description: "OpenAI's GPT-4",
220+
maxTokens: undefined,
221+
contextWindow: 128000,
222+
supportsPromptCache: true,
223+
inputPrice: 0.01,
224+
outputPrice: 0.03,
225+
},
208226
}
209227
const nonAnthropicHandler = new UnboundHandler(nonAnthropicOptions)
210228

@@ -230,10 +248,11 @@ describe("UnboundHandler", () => {
230248
it("should return default model when invalid model provided", () => {
231249
const handlerWithInvalidModel = new UnboundHandler({
232250
...mockOptions,
233-
apiModelId: "invalid/model",
251+
unboundModelId: "invalid/model",
252+
unboundModelInfo: undefined,
234253
})
235254
const modelInfo = handlerWithInvalidModel.getModel()
236-
expect(modelInfo.id).toBe("openai/gpt-4o") // Default model
255+
expect(modelInfo.id).toBe("anthropic/claude-3-5-sonnet-20241022") // Default model
237256
expect(modelInfo.info).toBeDefined()
238257
})
239258
})

src/api/providers/unbound.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Anthropic } from "@anthropic-ai/sdk"
22
import OpenAI from "openai"
33
import { ApiHandler, SingleCompletionHandler } from "../"
4-
import { ApiHandlerOptions, ModelInfo, UnboundModelId, unboundDefaultModelId, unboundModels } from "../../shared/api"
4+
import { ApiHandlerOptions, ModelInfo, unboundDefaultModelId, unboundDefaultModelInfo } from "../../shared/api"
55
import { convertToOpenAiMessages } from "../transform/openai-format"
66
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
77

@@ -129,15 +129,15 @@ export class UnboundHandler implements ApiHandler, SingleCompletionHandler {
129129
}
130130
}
131131

132-
getModel(): { id: UnboundModelId; info: ModelInfo } {
133-
const modelId = this.options.apiModelId
134-
if (modelId && modelId in unboundModels) {
135-
const id = modelId as UnboundModelId
136-
return { id, info: unboundModels[id] }
132+
getModel(): { id: string; info: ModelInfo } {
133+
const modelId = this.options.unboundModelId
134+
const modelInfo = this.options.unboundModelInfo
135+
if (modelId && modelInfo) {
136+
return { id: modelId, info: modelInfo }
137137
}
138138
return {
139139
id: unboundDefaultModelId,
140-
info: unboundModels[unboundDefaultModelId],
140+
info: unboundDefaultModelInfo,
141141
}
142142
}
143143

src/core/webview/ClineProvider.ts

Lines changed: 77 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,15 @@ type GlobalStateKey =
122122
| "autoApprovalEnabled"
123123
| "customModes" // Array of custom modes
124124
| "unboundModelId"
125+
| "unboundModelInfo"
125126

126127
export const GlobalFileNames = {
127128
apiConversationHistory: "api_conversation_history.json",
128129
uiMessages: "ui_messages.json",
129130
glamaModels: "glama_models.json",
130131
openRouterModels: "openrouter_models.json",
131132
mcpSettings: "cline_mcp_settings.json",
133+
unboundModels: "unbound_models.json",
132134
}
133135

134136
export class ClineProvider implements vscode.WebviewViewProvider {
@@ -665,6 +667,24 @@ export class ClineProvider implements vscode.WebviewViewProvider {
665667
}
666668
})
667669

670+
this.readUnboundModels().then((unboundModels) => {
671+
if (unboundModels) {
672+
this.postMessageToWebview({ type: "unboundModels", unboundModels })
673+
}
674+
})
675+
this.refreshUnboundModels().then(async (unboundModels) => {
676+
if (unboundModels) {
677+
const { apiConfiguration } = await this.getState()
678+
if (apiConfiguration?.unboundModelId) {
679+
await this.updateGlobalState(
680+
"unboundModelInfo",
681+
unboundModels[apiConfiguration.unboundModelId],
682+
)
683+
await this.postStateToWebview()
684+
}
685+
}
686+
})
687+
668688
this.configManager
669689
.listConfig()
670690
.then(async (listApiConfig) => {
@@ -824,6 +844,9 @@ export class ClineProvider implements vscode.WebviewViewProvider {
824844
this.postMessageToWebview({ type: "openAiModels", openAiModels })
825845
}
826846
break
847+
case "refreshUnboundModels":
848+
await this.refreshUnboundModels()
849+
break
827850
case "openImage":
828851
openImage(message.text!)
829852
break
@@ -1563,6 +1586,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
15631586
mistralApiKey,
15641587
unboundApiKey,
15651588
unboundModelId,
1589+
unboundModelInfo,
15661590
} = apiConfiguration
15671591
await this.updateGlobalState("apiProvider", apiProvider)
15681592
await this.updateGlobalState("apiModelId", apiModelId)
@@ -1603,6 +1627,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
16031627
await this.storeSecret("mistralApiKey", mistralApiKey)
16041628
await this.storeSecret("unboundApiKey", unboundApiKey)
16051629
await this.updateGlobalState("unboundModelId", unboundModelId)
1630+
await this.updateGlobalState("unboundModelInfo", unboundModelInfo)
16061631
if (this.cline) {
16071632
this.cline.api = buildApiHandler(apiConfiguration)
16081633
}
@@ -1808,16 +1833,20 @@ export class ClineProvider implements vscode.WebviewViewProvider {
18081833
// await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome
18091834
}
18101835

1811-
async readGlamaModels(): Promise<Record<string, ModelInfo> | undefined> {
1812-
const glamaModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.glamaModels)
1813-
const fileExists = await fileExistsAtPath(glamaModelsFilePath)
1836+
private async readModelsFromCache(filename: string): Promise<Record<string, ModelInfo> | undefined> {
1837+
const filePath = path.join(await this.ensureCacheDirectoryExists(), filename)
1838+
const fileExists = await fileExistsAtPath(filePath)
18141839
if (fileExists) {
1815-
const fileContents = await fs.readFile(glamaModelsFilePath, "utf8")
1840+
const fileContents = await fs.readFile(filePath, "utf8")
18161841
return JSON.parse(fileContents)
18171842
}
18181843
return undefined
18191844
}
18201845

1846+
async readGlamaModels(): Promise<Record<string, ModelInfo> | undefined> {
1847+
return this.readModelsFromCache(GlobalFileNames.glamaModels)
1848+
}
1849+
18211850
async refreshGlamaModels() {
18221851
const glamaModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.glamaModels)
18231852

@@ -1893,16 +1922,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
18931922
}
18941923

18951924
async readOpenRouterModels(): Promise<Record<string, ModelInfo> | undefined> {
1896-
const openRouterModelsFilePath = path.join(
1897-
await this.ensureCacheDirectoryExists(),
1898-
GlobalFileNames.openRouterModels,
1899-
)
1900-
const fileExists = await fileExistsAtPath(openRouterModelsFilePath)
1901-
if (fileExists) {
1902-
const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8")
1903-
return JSON.parse(fileContents)
1904-
}
1905-
return undefined
1925+
return this.readModelsFromCache(GlobalFileNames.openRouterModels)
19061926
}
19071927

19081928
async refreshOpenRouterModels() {
@@ -2017,6 +2037,46 @@ export class ClineProvider implements vscode.WebviewViewProvider {
20172037
return models
20182038
}
20192039

2040+
async readUnboundModels(): Promise<Record<string, ModelInfo> | undefined> {
2041+
return this.readModelsFromCache(GlobalFileNames.unboundModels)
2042+
}
2043+
2044+
async refreshUnboundModels() {
2045+
const unboundModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.unboundModels)
2046+
2047+
const models: Record<string, ModelInfo> = {}
2048+
try {
2049+
const response = await axios.get("https://api.getunbound.ai/models")
2050+
2051+
if (response.data) {
2052+
const rawModels: Record<string, any> = response.data
2053+
2054+
for (const [modelId, model] of Object.entries(rawModels)) {
2055+
models[modelId] = {
2056+
maxTokens: model.maxTokens ? parseInt(model.maxTokens) : undefined,
2057+
contextWindow: model.contextWindow ? parseInt(model.contextWindow) : 0,
2058+
supportsImages: model.supportsImages ?? false,
2059+
supportsPromptCache: model.supportsPromptCaching ?? false,
2060+
supportsComputerUse: model.supportsComputerUse ?? false,
2061+
inputPrice: model.inputTokenPrice ? parseFloat(model.inputTokenPrice) : undefined,
2062+
outputPrice: model.outputTokenPrice ? parseFloat(model.outputTokenPrice) : undefined,
2063+
cacheWritesPrice: model.cacheWritePrice ? parseFloat(model.cacheWritePrice) : undefined,
2064+
cacheReadsPrice: model.cacheReadPrice ? parseFloat(model.cacheReadPrice) : undefined,
2065+
}
2066+
}
2067+
}
2068+
await fs.writeFile(unboundModelsFilePath, JSON.stringify(models))
2069+
this.outputChannel.appendLine(`Unbound models fetched and saved: ${JSON.stringify(models, null, 2)}`)
2070+
} catch (error) {
2071+
this.outputChannel.appendLine(
2072+
`Error fetching Unbound models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`,
2073+
)
2074+
}
2075+
2076+
await this.postMessageToWebview({ type: "unboundModels", unboundModels: models })
2077+
return models
2078+
}
2079+
20202080
// Task history
20212081

20222082
async getTaskWithId(id: string): Promise<{
@@ -2330,6 +2390,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
23302390
experiments,
23312391
unboundApiKey,
23322392
unboundModelId,
2393+
unboundModelInfo,
23332394
] = await Promise.all([
23342395
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
23352396
this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -2405,6 +2466,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
24052466
this.getGlobalState("experiments") as Promise<Record<ExperimentId, boolean> | undefined>,
24062467
this.getSecret("unboundApiKey") as Promise<string | undefined>,
24072468
this.getGlobalState("unboundModelId") as Promise<string | undefined>,
2469+
this.getGlobalState("unboundModelInfo") as Promise<ModelInfo | undefined>,
24082470
])
24092471

24102472
let apiProvider: ApiProvider
@@ -2462,6 +2524,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
24622524
vsCodeLmModelSelector,
24632525
unboundApiKey,
24642526
unboundModelId,
2527+
unboundModelInfo,
24652528
},
24662529
lastShownAnnouncementId,
24672530
customInstructions,

src/shared/ExtensionMessage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ export interface ExtensionMessage {
4242
| "autoApprovalEnabled"
4343
| "updateCustomMode"
4444
| "deleteCustomMode"
45+
| "unboundModels"
46+
| "refreshUnboundModels"
4547
| "currentCheckpointUpdated"
4648
text?: string
4749
action?:
@@ -67,6 +69,7 @@ export interface ExtensionMessage {
6769
glamaModels?: Record<string, ModelInfo>
6870
openRouterModels?: Record<string, ModelInfo>
6971
openAiModels?: string[]
72+
unboundModels?: Record<string, ModelInfo>
7073
mcpServers?: McpServer[]
7174
commits?: GitCommit[]
7275
listApiConfig?: ApiConfigMeta[]

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export interface WebviewMessage {
4242
| "refreshGlamaModels"
4343
| "refreshOpenRouterModels"
4444
| "refreshOpenAiModels"
45+
| "refreshUnboundModels"
4546
| "alwaysAllowBrowser"
4647
| "alwaysAllowMcp"
4748
| "alwaysAllowModeSwitch"

src/shared/api.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export interface ApiHandlerOptions {
6060
includeMaxTokens?: boolean
6161
unboundApiKey?: string
6262
unboundModelId?: string
63+
unboundModelInfo?: ModelInfo
6364
}
6465

6566
export type ApiConfiguration = ApiHandlerOptions & {
@@ -650,12 +651,14 @@ export const mistralModels = {
650651
} as const satisfies Record<string, ModelInfo>
651652

652653
// Unbound Security
653-
export type UnboundModelId = keyof typeof unboundModels
654-
export const unboundDefaultModelId = "openai/gpt-4o"
655-
export const unboundModels = {
656-
"anthropic/claude-3-5-sonnet-20241022": anthropicModels["claude-3-5-sonnet-20241022"],
657-
"openai/gpt-4o": openAiNativeModels["gpt-4o"],
658-
"deepseek/deepseek-chat": deepSeekModels["deepseek-chat"],
659-
"deepseek/deepseek-reasoner": deepSeekModels["deepseek-reasoner"],
660-
"mistral/codestral-latest": mistralModels["codestral-latest"],
661-
} as const satisfies Record<string, ModelInfo>
654+
export const unboundDefaultModelId = "anthropic/claude-3-5-sonnet-20241022"
655+
export const unboundDefaultModelInfo: ModelInfo = {
656+
maxTokens: 8192,
657+
contextWindow: 200_000,
658+
supportsImages: true,
659+
supportsPromptCache: true,
660+
inputPrice: 3.0,
661+
outputPrice: 15.0,
662+
cacheWritesPrice: 3.75,
663+
cacheReadsPrice: 0.3,
664+
}

webview-ui/src/components/settings/ApiOptions.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
vertexDefaultModelId,
2929
vertexModels,
3030
unboundDefaultModelId,
31-
unboundModels,
31+
unboundDefaultModelInfo,
3232
} from "../../../../src/shared/api"
3333
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
3434
import { useExtensionState } from "../../context/ExtensionStateContext"
@@ -37,9 +37,11 @@ import VSCodeButtonLink from "../common/VSCodeButtonLink"
3737
import { OpenRouterModelPicker } from "./OpenRouterModelPicker"
3838
import OpenAiModelPicker from "./OpenAiModelPicker"
3939
import { GlamaModelPicker } from "./GlamaModelPicker"
40+
import { UnboundModelPicker } from "./UnboundModelPicker"
4041
import { ModelInfoView } from "./ModelInfoView"
4142
import { DROPDOWN_Z_INDEX } from "./styles"
4243

44+
4345
interface ApiOptionsProps {
4446
apiErrorMessage?: string
4547
modelIdErrorMessage?: string
@@ -1314,6 +1316,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
13141316
}}>
13151317
This key is stored locally and only used to make API requests from this extension.
13161318
</p>
1319+
<UnboundModelPicker />
13171320
</div>
13181321
)}
13191322

@@ -1336,7 +1339,8 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
13361339
selectedProvider !== "openrouter" &&
13371340
selectedProvider !== "openai" &&
13381341
selectedProvider !== "ollama" &&
1339-
selectedProvider !== "lmstudio" && (
1342+
selectedProvider !== "lmstudio" &&
1343+
selectedProvider !== "unbound" && (
13401344
<>
13411345
<div className="dropdown-container">
13421346
<label htmlFor="model-id">
@@ -1349,7 +1353,6 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
13491353
{selectedProvider === "openai-native" && createDropdown(openAiNativeModels)}
13501354
{selectedProvider === "deepseek" && createDropdown(deepSeekModels)}
13511355
{selectedProvider === "mistral" && createDropdown(mistralModels)}
1352-
{selectedProvider === "unbound" && createDropdown(unboundModels)}
13531356
</div>
13541357

13551358
<ModelInfoView
@@ -1458,7 +1461,11 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
14581461
},
14591462
}
14601463
case "unbound":
1461-
return getProviderData(unboundModels, unboundDefaultModelId)
1464+
return {
1465+
selectedProvider: provider,
1466+
selectedModelId: apiConfiguration?.unboundModelId || unboundDefaultModelId,
1467+
selectedModelInfo: apiConfiguration?.unboundModelInfo || unboundDefaultModelInfo,
1468+
}
14621469
default:
14631470
return getProviderData(anthropicModels, anthropicDefaultModelId)
14641471
}

webview-ui/src/components/settings/ModelPicker.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,10 @@ import { ModelInfoView } from "./ModelInfoView"
2525

2626
interface ModelPickerProps {
2727
defaultModelId: string
28-
modelsKey: "glamaModels" | "openRouterModels"
29-
configKey: "glamaModelId" | "openRouterModelId"
30-
infoKey: "glamaModelInfo" | "openRouterModelInfo"
31-
refreshMessageType: "refreshGlamaModels" | "refreshOpenRouterModels"
28+
modelsKey: "glamaModels" | "openRouterModels" | "unboundModels"
29+
configKey: "glamaModelId" | "openRouterModelId" | "unboundModelId"
30+
infoKey: "glamaModelInfo" | "openRouterModelInfo" | "unboundModelInfo"
31+
refreshMessageType: "refreshGlamaModels" | "refreshOpenRouterModels" | "refreshUnboundModels"
3232
serviceName: string
3333
serviceUrl: string
3434
recommendedModel: string

0 commit comments

Comments
 (0)