Skip to content

Commit 23fd205

Browse files
authored
Merge pull request #297 from RooVetGit/api_config
Save different API configurations to quickly switch between providers and settings
2 parents c30e9c6 + 9d16006 commit 23fd205

22 files changed

+1585
-139
lines changed

.changeset/shiny-seahorses-peel.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"roo-cline": patch
3+
---
4+
5+
Save different API configurations to quickly switch between providers and settings (thanks @samhvw8!)

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ A fork of Cline, an autonomous coding agent, with some additional experimental f
77
- Drag and drop images into chats
88
- Delete messages from chats
99
- @-mention Git commits to include their context in the chat
10+
- Save different API configurations to quickly switch between providers and settings
1011
- "Enhance prompt" button (OpenRouter models only for now)
1112
- Sound effects for feedback
1213
- Option to use browsers of different sizes and adjust screenshot quality

src/core/Cline.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -799,8 +799,30 @@ export class Cline {
799799
}
800800
}
801801

802-
// Convert to Anthropic.MessageParam by spreading only the API-required properties
803-
const cleanConversationHistory = this.apiConversationHistory.map(({ role, content }) => ({ role, content }))
802+
// Clean conversation history by:
803+
// 1. Converting to Anthropic.MessageParam by spreading only the API-required properties
804+
// 2. Converting image blocks to text descriptions if model doesn't support images
805+
const cleanConversationHistory = this.apiConversationHistory.map(({ role, content }) => {
806+
// Handle array content (could contain image blocks)
807+
if (Array.isArray(content)) {
808+
if (!this.api.getModel().info.supportsImages) {
809+
// Convert image blocks to text descriptions
810+
content = content.map(block => {
811+
if (block.type === 'image') {
812+
// Convert image blocks to text descriptions
813+
// Note: We can't access the actual image content/url due to API limitations,
814+
// but we can indicate that an image was present in the conversation
815+
return {
816+
type: 'text',
817+
text: '[Referenced image in conversation]'
818+
};
819+
}
820+
return block;
821+
});
822+
}
823+
}
824+
return { role, content }
825+
})
804826
const stream = this.api.createMessage(systemPrompt, cleanConversationHistory)
805827
const iterator = stream[Symbol.asyncIterator]()
806828

src/core/__tests__/Cline.test.ts

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Cline } from '../Cline';
22
import { ClineProvider } from '../webview/ClineProvider';
3-
import { ApiConfiguration } from '../../shared/api';
3+
import { ApiConfiguration, ModelInfo } from '../../shared/api';
44
import { ApiStreamChunk } from '../../api/transform/stream';
5+
import { Anthropic } from '@anthropic-ai/sdk';
56
import * as vscode from 'vscode';
67

78
// Mock all MCP-related modules
@@ -498,6 +499,133 @@ describe('Cline', () => {
498499
expect(passedMessage).not.toHaveProperty('ts');
499500
expect(passedMessage).not.toHaveProperty('extraProp');
500501
});
502+
503+
it('should handle image blocks based on model capabilities', async () => {
504+
// Create two configurations - one with image support, one without
505+
const configWithImages = {
506+
...mockApiConfig,
507+
apiModelId: 'claude-3-sonnet'
508+
};
509+
const configWithoutImages = {
510+
...mockApiConfig,
511+
apiModelId: 'gpt-3.5-turbo'
512+
};
513+
514+
// Create test conversation history with mixed content
515+
const conversationHistory: (Anthropic.MessageParam & { ts?: number })[] = [
516+
{
517+
role: 'user' as const,
518+
content: [
519+
{
520+
type: 'text' as const,
521+
text: 'Here is an image'
522+
} satisfies Anthropic.TextBlockParam,
523+
{
524+
type: 'image' as const,
525+
source: {
526+
type: 'base64' as const,
527+
media_type: 'image/jpeg',
528+
data: 'base64data'
529+
}
530+
} satisfies Anthropic.ImageBlockParam
531+
]
532+
},
533+
{
534+
role: 'assistant' as const,
535+
content: [{
536+
type: 'text' as const,
537+
text: 'I see the image'
538+
} satisfies Anthropic.TextBlockParam]
539+
}
540+
];
541+
542+
// Test with model that supports images
543+
const clineWithImages = new Cline(
544+
mockProvider,
545+
configWithImages,
546+
undefined,
547+
false,
548+
undefined,
549+
'test task'
550+
);
551+
// Mock the model info to indicate image support
552+
jest.spyOn(clineWithImages.api, 'getModel').mockReturnValue({
553+
id: 'claude-3-sonnet',
554+
info: {
555+
supportsImages: true,
556+
supportsPromptCache: true,
557+
supportsComputerUse: true,
558+
contextWindow: 200000,
559+
maxTokens: 4096,
560+
inputPrice: 0.25,
561+
outputPrice: 0.75
562+
} as ModelInfo
563+
});
564+
clineWithImages.apiConversationHistory = conversationHistory;
565+
566+
// Test with model that doesn't support images
567+
const clineWithoutImages = new Cline(
568+
mockProvider,
569+
configWithoutImages,
570+
undefined,
571+
false,
572+
undefined,
573+
'test task'
574+
);
575+
// Mock the model info to indicate no image support
576+
jest.spyOn(clineWithoutImages.api, 'getModel').mockReturnValue({
577+
id: 'gpt-3.5-turbo',
578+
info: {
579+
supportsImages: false,
580+
supportsPromptCache: false,
581+
supportsComputerUse: false,
582+
contextWindow: 16000,
583+
maxTokens: 2048,
584+
inputPrice: 0.1,
585+
outputPrice: 0.2
586+
} as ModelInfo
587+
});
588+
clineWithoutImages.apiConversationHistory = conversationHistory;
589+
590+
// Create message spy for both instances
591+
const createMessageSpyWithImages = jest.fn();
592+
const createMessageSpyWithoutImages = jest.fn();
593+
const mockStream = {
594+
async *[Symbol.asyncIterator]() {
595+
yield { type: 'text', text: '' };
596+
}
597+
} as AsyncGenerator<ApiStreamChunk>;
598+
599+
jest.spyOn(clineWithImages.api, 'createMessage').mockImplementation((...args) => {
600+
createMessageSpyWithImages(...args);
601+
return mockStream;
602+
});
603+
jest.spyOn(clineWithoutImages.api, 'createMessage').mockImplementation((...args) => {
604+
createMessageSpyWithoutImages(...args);
605+
return mockStream;
606+
});
607+
608+
// Trigger API requests for both instances
609+
await clineWithImages.recursivelyMakeClineRequests([{ type: 'text', text: 'test' }]);
610+
await clineWithoutImages.recursivelyMakeClineRequests([{ type: 'text', text: 'test' }]);
611+
612+
// Verify model with image support preserves image blocks
613+
const callsWithImages = createMessageSpyWithImages.mock.calls;
614+
const historyWithImages = callsWithImages[0][1][0];
615+
expect(historyWithImages.content).toHaveLength(2);
616+
expect(historyWithImages.content[0]).toEqual({ type: 'text', text: 'Here is an image' });
617+
expect(historyWithImages.content[1]).toHaveProperty('type', 'image');
618+
619+
// Verify model without image support converts image blocks to text
620+
const callsWithoutImages = createMessageSpyWithoutImages.mock.calls;
621+
const historyWithoutImages = callsWithoutImages[0][1][0];
622+
expect(historyWithoutImages.content).toHaveLength(2);
623+
expect(historyWithoutImages.content[0]).toEqual({ type: 'text', text: 'Here is an image' });
624+
expect(historyWithoutImages.content[1]).toEqual({
625+
type: 'text',
626+
text: '[Referenced image in conversation]'
627+
});
628+
});
501629
});
502630
});
503631
});

src/core/config/ConfigManager.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { ExtensionContext } from 'vscode'
2+
import { ApiConfiguration } from '../../shared/api'
3+
import { ApiConfigMeta } from '../../shared/ExtensionMessage'
4+
5+
export interface ApiConfigData {
6+
currentApiConfigName: string
7+
apiConfigs: {
8+
[key: string]: ApiConfiguration
9+
}
10+
}
11+
12+
export class ConfigManager {
13+
private readonly defaultConfig: ApiConfigData = {
14+
currentApiConfigName: 'default',
15+
apiConfigs: {
16+
default: {}
17+
}
18+
}
19+
private readonly SCOPE_PREFIX = "roo_cline_config_"
20+
private readonly context: ExtensionContext
21+
22+
constructor(context: ExtensionContext) {
23+
this.context = context
24+
}
25+
26+
/**
27+
* Initialize config if it doesn't exist
28+
*/
29+
async initConfig(): Promise<void> {
30+
try {
31+
const config = await this.readConfig()
32+
if (!config) {
33+
await this.writeConfig(this.defaultConfig)
34+
}
35+
} catch (error) {
36+
throw new Error(`Failed to initialize config: ${error}`)
37+
}
38+
}
39+
40+
/**
41+
* List all available configs with metadata
42+
*/
43+
async ListConfig(): Promise<ApiConfigMeta[]> {
44+
try {
45+
const config = await this.readConfig()
46+
return Object.entries(config.apiConfigs).map(([name, apiConfig]) => ({
47+
name,
48+
apiProvider: apiConfig.apiProvider,
49+
}))
50+
} catch (error) {
51+
throw new Error(`Failed to list configs: ${error}`)
52+
}
53+
}
54+
55+
/**
56+
* Save a config with the given name
57+
*/
58+
async SaveConfig(name: string, config: ApiConfiguration): Promise<void> {
59+
try {
60+
const currentConfig = await this.readConfig()
61+
currentConfig.apiConfigs[name] = config
62+
await this.writeConfig(currentConfig)
63+
} catch (error) {
64+
throw new Error(`Failed to save config: ${error}`)
65+
}
66+
}
67+
68+
/**
69+
* Load a config by name
70+
*/
71+
async LoadConfig(name: string): Promise<ApiConfiguration> {
72+
try {
73+
const config = await this.readConfig()
74+
const apiConfig = config.apiConfigs[name]
75+
76+
if (!apiConfig) {
77+
throw new Error(`Config '${name}' not found`)
78+
}
79+
80+
config.currentApiConfigName = name;
81+
await this.writeConfig(config)
82+
83+
return apiConfig
84+
} catch (error) {
85+
throw new Error(`Failed to load config: ${error}`)
86+
}
87+
}
88+
89+
/**
90+
* Delete a config by name
91+
*/
92+
async DeleteConfig(name: string): Promise<void> {
93+
try {
94+
const currentConfig = await this.readConfig()
95+
if (!currentConfig.apiConfigs[name]) {
96+
throw new Error(`Config '${name}' not found`)
97+
}
98+
99+
// Don't allow deleting the default config
100+
if (Object.keys(currentConfig.apiConfigs).length === 1) {
101+
throw new Error(`Cannot delete the last remaining configuration.`)
102+
}
103+
104+
delete currentConfig.apiConfigs[name]
105+
await this.writeConfig(currentConfig)
106+
} catch (error) {
107+
throw new Error(`Failed to delete config: ${error}`)
108+
}
109+
}
110+
111+
/**
112+
* Set the current active API configuration
113+
*/
114+
async SetCurrentConfig(name: string): Promise<void> {
115+
try {
116+
const currentConfig = await this.readConfig()
117+
if (!currentConfig.apiConfigs[name]) {
118+
throw new Error(`Config '${name}' not found`)
119+
}
120+
121+
currentConfig.currentApiConfigName = name
122+
await this.writeConfig(currentConfig)
123+
} catch (error) {
124+
throw new Error(`Failed to set current config: ${error}`)
125+
}
126+
}
127+
128+
/**
129+
* Check if a config exists by name
130+
*/
131+
async HasConfig(name: string): Promise<boolean> {
132+
try {
133+
const config = await this.readConfig()
134+
return name in config.apiConfigs
135+
} catch (error) {
136+
throw new Error(`Failed to check config existence: ${error}`)
137+
}
138+
}
139+
140+
private async readConfig(): Promise<ApiConfigData> {
141+
try {
142+
const configKey = `${this.SCOPE_PREFIX}api_config`
143+
const content = await this.context.secrets.get(configKey)
144+
145+
if (!content) {
146+
return this.defaultConfig
147+
}
148+
149+
return JSON.parse(content)
150+
} catch (error) {
151+
throw new Error(`Failed to read config from secrets: ${error}`)
152+
}
153+
}
154+
155+
private async writeConfig(config: ApiConfigData): Promise<void> {
156+
try {
157+
const configKey = `${this.SCOPE_PREFIX}api_config`
158+
const content = JSON.stringify(config, null, 2)
159+
await this.context.secrets.store(configKey, content)
160+
} catch (error) {
161+
throw new Error(`Failed to write config to secrets: ${error}`)
162+
}
163+
}
164+
}

0 commit comments

Comments
 (0)