diff --git a/extensions/cli/spec/onboarding.md b/extensions/cli/spec/onboarding.md index 58c65132292..1a38ef5c0ea 100644 --- a/extensions/cli/spec/onboarding.md +++ b/extensions/cli/spec/onboarding.md @@ -10,7 +10,6 @@ When a user first runs `cn` in interactive mode, they will be taken through "onb 2. If the CONTINUE_USE_BEDROCK environment variable is set to "1", automatically use AWS Bedrock configuration and skip interactive prompts 3. Present the user with available options: - - Log in with Continue: log them in, which will automatically create their assistant and then we can load the first assistant from the first org - Enter your Anthropic API key: let them enter the key, and then either create a ~/.continue/config.yaml with the following contents OR update the existing config.yaml to add the model ```yaml @@ -19,9 +18,24 @@ When a user first runs `cn` in interactive mode, they will be taken through "onb schema: v1 models: - - uses: anthropic/claude-4-sonnet - with: - ANTHROPIC_API_KEY: + - name: claude sonnet 4.6 + provider: anthropic + model: claude-sonnet-4-6 + apiKey: + ``` + + - Enter your OpenAI API key: let them enter the key, and then either create a ~/.continue/config.yaml with the following contents OR update the existing config.yaml to add the model + + ```yaml + name: Local Config + version: 1.0.0 + schema: v1 + + models: + - name: GPT 5.4 + provider: openai + model: gpt-5.4 + apiKey: ``` When CONTINUE_USE_BEDROCK=1 is detected, it will use AWS Bedrock configuration. The user must have AWS credentials configured through the standard AWS credential chain (AWS CLI, environment variables, IAM roles, etc.). diff --git a/extensions/cli/src/onboarding.ts b/extensions/cli/src/onboarding.ts index 2a8162284de..b902cbf79ce 100644 --- a/extensions/cli/src/onboarding.ts +++ b/extensions/cli/src/onboarding.ts @@ -4,16 +4,20 @@ import * as path from "path"; import chalk from "chalk"; import { setConfigFilePermissions } from "core/util/paths.js"; -import { AuthConfig, login } from "./auth/workos.js"; +import { AuthConfig } from "./auth/workos.js"; import { getApiClient } from "./config.js"; import { loadConfiguration } from "./configLoader.js"; import { env } from "./env.js"; import { getApiKeyValidationError, isValidAnthropicApiKey, + isValidOpenAIApiKey, } from "./util/apiKeyValidation.js"; import { question, questionWithChoices } from "./util/prompt.js"; -import { updateAnthropicModelInYaml } from "./util/yamlConfigUpdater.js"; +import { + updateAnthropicModelInYaml, + updateOpenAIModelInYaml, +} from "./util/yamlConfigUpdater.js"; const CONFIG_PATH = path.join(env.continueHome, "config.yaml"); @@ -32,7 +36,9 @@ export async function checkHasAcceptableModel( } } -export async function createOrUpdateConfig(apiKey: string): Promise { +export async function createOrUpdateConfigAnthropic( + apiKey: string, +): Promise { const configDir = path.dirname(CONFIG_PATH); if (!fs.existsSync(configDir)) { @@ -48,6 +54,24 @@ export async function createOrUpdateConfig(apiKey: string): Promise { setConfigFilePermissions(CONFIG_PATH); } +export async function createOrUpdateConfigOpenAI( + apiKey: string, +): Promise { + const configDir = path.dirname(CONFIG_PATH); + + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + + const existingContent = fs.existsSync(CONFIG_PATH) + ? fs.readFileSync(CONFIG_PATH, "utf8") + : ""; + + const updatedContent = updateOpenAIModelInYaml(existingContent, apiKey); + fs.writeFileSync(CONFIG_PATH, updatedContent); + setConfigFilePermissions(CONFIG_PATH); +} + export async function runOnboardingFlow( configPath: string | undefined, ): Promise { @@ -76,7 +100,7 @@ export async function runOnboardingFlow( // In test/CI environment, check for ANTHROPIC_API_KEY first if (process.env.ANTHROPIC_API_KEY) { console.log(chalk.blue("✓ Using ANTHROPIC_API_KEY from environment")); - await createOrUpdateConfig(process.env.ANTHROPIC_API_KEY); + await createOrUpdateConfigAnthropic(process.env.ANTHROPIC_API_KEY); console.log(chalk.gray(` Config saved to: ${CONFIG_PATH}`)); return false; } @@ -87,8 +111,8 @@ export async function runOnboardingFlow( // Step 4: Present user with two options console.log(chalk.yellow("How do you want to get started?")); - console.log(chalk.white("1. ⏩ Log in with Continue")); - console.log(chalk.white("2. 🔑 Enter your Anthropic API key")); + console.log(chalk.white("1. 🔑 Enter your Anthropic API key")); + console.log(chalk.white("2. 🔑 Enter your OpenAI API key")); const choice = await questionWithChoices( chalk.yellow("\nEnter choice (1): "), @@ -98,18 +122,28 @@ export async function runOnboardingFlow( ); if (choice === "1" || choice === "") { - await login(); - return true; - } else if (choice === "2") { const apiKey = await question( chalk.white("\nEnter your Anthropic API key: "), ); if (!isValidAnthropicApiKey(apiKey)) { - throw new Error(getApiKeyValidationError(apiKey)); + throw new Error(getApiKeyValidationError(apiKey, "anthropic")); + } + + await createOrUpdateConfigAnthropic(apiKey); + console.log( + chalk.green(`✓ Config file updated successfully at ${CONFIG_PATH}`), + ); + + return true; + } else if (choice === "2") { + const apiKey = await question(chalk.white("\nEnter your OpenAI API key: ")); + + if (!isValidOpenAIApiKey(apiKey)) { + throw new Error(getApiKeyValidationError(apiKey, "openai")); } - await createOrUpdateConfig(apiKey); + await createOrUpdateConfigOpenAI(apiKey); console.log( chalk.green(`✓ Config file updated successfully at ${CONFIG_PATH}`), ); diff --git a/extensions/cli/src/services/index.ts b/extensions/cli/src/services/index.ts index bebe44c9dfc..6f1e7134fad 100644 --- a/extensions/cli/src/services/index.ts +++ b/extensions/cli/src/services/index.ts @@ -84,7 +84,8 @@ export async function initializeServices(initOptions: ServiceInitOptions = {}) { !commandOptions.config && process.env.ANTHROPIC_API_KEY ) { - const { createOrUpdateConfig } = await import("../onboarding.js"); + const { createOrUpdateConfigAnthropic: createOrUpdateConfig } = + await import("../onboarding.js"); const { env } = await import("../env.js"); const path = await import("path"); diff --git a/extensions/cli/src/util/apiKeyValidation.ts b/extensions/cli/src/util/apiKeyValidation.ts index f6fbd896c96..41af819b516 100644 --- a/extensions/cli/src/util/apiKeyValidation.ts +++ b/extensions/cli/src/util/apiKeyValidation.ts @@ -14,24 +14,51 @@ export function isValidAnthropicApiKey( return apiKey.startsWith("sk-ant-") && apiKey.length > "sk-ant-".length; } +/** + * Validates an OpenAI API key format + * @param apiKey The API key to validate + * @returns true if the API key is valid, false otherwise + */ +export function isValidOpenAIApiKey( + apiKey: string | null | undefined, +): boolean { + if (!apiKey || typeof apiKey !== "string") { + return false; + } + + return apiKey.startsWith("sk-") && apiKey.length > "sk-".length; +} + /** * Gets a user-friendly error message for invalid API keys * @param apiKey The API key that failed validation + * @param provider The provider name (e.g., "Anthropic", "OpenAI") * @returns A descriptive error message */ export function getApiKeyValidationError( apiKey: string | null | undefined, + provider: "anthropic" | "openai" = "anthropic", ): string { if (!apiKey || typeof apiKey !== "string") { return "API key is required"; } - if (!apiKey.startsWith("sk-ant-")) { - return 'API key must start with "sk-ant-"'; - } + if (provider === "anthropic") { + if (!apiKey.startsWith("sk-ant-")) { + return 'API key must start with "sk-ant-"'; + } + + if (apiKey.length <= "sk-ant-".length) { + return "API key is too short"; + } + } else if (provider === "openai") { + if (!apiKey.startsWith("sk-")) { + return 'API key must start with "sk-"'; + } - if (apiKey.length <= "sk-ant-".length) { - return "API key is too short"; + if (apiKey.length <= "sk-".length) { + return "API key is too short"; + } } return "Invalid API key format"; diff --git a/extensions/cli/src/util/yamlConfigUpdater.test.ts b/extensions/cli/src/util/yamlConfigUpdater.test.ts index e0524e6028c..f2b3e996cd3 100644 --- a/extensions/cli/src/util/yamlConfigUpdater.test.ts +++ b/extensions/cli/src/util/yamlConfigUpdater.test.ts @@ -1,6 +1,9 @@ import { parse } from "yaml"; -import { updateAnthropicModelInYaml } from "./yamlConfigUpdater.js"; +import { + updateAnthropicModelInYaml, + updateOpenAIModelInYaml, +} from "./yamlConfigUpdater.js"; describe("updateAnthropicModelInYaml", () => { const testApiKey = "sk-ant-test123456789"; @@ -12,8 +15,9 @@ describe("updateAnthropicModelInYaml", () => { expect(result).toContain("name: Local Config"); expect(result).toContain("version: 1.0.0"); expect(result).toContain("schema: v1"); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); + expect(result).toContain("provider: anthropic"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("apiKey: sk-ant-test123456789"); }); it("should create new config from invalid YAML", () => { @@ -21,8 +25,9 @@ describe("updateAnthropicModelInYaml", () => { const result = updateAnthropicModelInYaml(invalidYaml, testApiKey); expect(result).toContain("name: Local Config"); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); + expect(result).toContain("provider: anthropic"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("apiKey: sk-ant-test123456789"); }); }); @@ -34,18 +39,19 @@ version: 1.0.0 schema: v1 # List of available models models: - - uses: openai/gpt-4 - with: - OPENAI_API_KEY: TEST-openai-test + - name: GPT 4 + provider: openai + model: gpt-4 + apiKey: TEST-openai-test `; const result = updateAnthropicModelInYaml(yamlWithComments, testApiKey); expect(result).toContain("# My Continue config"); expect(result).toContain("# List of available models"); - expect(result).toContain("uses: openai/gpt-4"); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); + expect(result).toContain("provider: openai"); + expect(result).toContain("provider: anthropic"); + expect(result).toContain("apiKey: sk-ant-test123456789"); }); it("should preserve comments when updating existing model", () => { @@ -55,17 +61,19 @@ version: 1.0.0 schema: v1 # List of available models models: - - uses: anthropic/claude-sonnet-4-6 - with: - ANTHROPIC_API_KEY: old-key + - name: claude sonnet 4.6 + provider: anthropic + model: claude-sonnet-4-6 + apiKey: old-key `; const result = updateAnthropicModelInYaml(yamlWithComments, testApiKey); expect(result).toContain("# My Continue config"); expect(result).toContain("# List of available models"); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); + expect(result).toContain("provider: anthropic"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("apiKey: sk-ant-test123456789"); expect(result).not.toContain("old-key"); }); }); @@ -76,17 +84,19 @@ models: version: 1.0.0 schema: v1 models: - - uses: openai/gpt-4 - with: - OPENAI_API_KEY: TEST-openai-test + - name: GPT 4 + provider: openai + model: gpt-4 + apiKey: TEST-openai-test `; const result = updateAnthropicModelInYaml(existingConfig, testApiKey); - expect(result).toContain("uses: openai/gpt-4"); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); - expect(result).toContain("OPENAI_API_KEY: TEST-openai-test"); + expect(result).toContain("provider: openai"); + expect(result).toContain("provider: anthropic"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("apiKey: sk-ant-test123456789"); + expect(result).toContain("apiKey: TEST-openai-test"); }); it("should update existing anthropic model", () => { @@ -94,26 +104,26 @@ models: version: 1.0.0 schema: v1 models: - - uses: anthropic/claude-sonnet-4-6 - with: - ANTHROPIC_API_KEY: old-anthropic-key - - uses: openai/gpt-4 - with: - OPENAI_API_KEY: TEST-openai-test + - name: claude sonnet 4.6 + provider: anthropic + model: claude-sonnet-4-6 + apiKey: old-anthropic-key + - name: GPT 4 + provider: openai + model: gpt-4 + apiKey: TEST-openai-test `; const result = updateAnthropicModelInYaml(existingConfig, testApiKey); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("uses: openai/gpt-4"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); - expect(result).toContain("OPENAI_API_KEY: TEST-openai-test"); + expect(result).toContain("provider: anthropic"); + expect(result).toContain("provider: openai"); + expect(result).toContain("apiKey: sk-ant-test123456789"); + expect(result).toContain("apiKey: TEST-openai-test"); expect(result).not.toContain("old-anthropic-key"); // Should only have one anthropic model - const anthropicMatches = result.match( - /uses: anthropic\/claude-sonnet-4-6/g, - ); + const anthropicMatches = result.match(/model: claude-sonnet-4-6/g); expect(anthropicMatches).toHaveLength(1); }); @@ -130,8 +140,9 @@ schema: v1 expect(result).toContain("name: Local Config"); expect(result).toContain("models:"); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); + expect(result).toContain("provider: anthropic"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("apiKey: sk-ant-test123456789"); }); it("should handle config with empty models array", () => { @@ -147,8 +158,9 @@ models: [] ); expect(result).toContain("name: Local Config"); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); + expect(result).toContain("provider: anthropic"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("apiKey: sk-ant-test123456789"); }); }); @@ -157,7 +169,10 @@ models: [] const input = `# Test config name: Test models: - - uses: existing/model + - name: existing + provider: test + model: test-model + apiKey: test-key `; const result = updateAnthropicModelInYaml(input, testApiKey); @@ -175,9 +190,10 @@ models: expect(result).toMatch(/^version: /m); expect(result).toMatch(/^schema: /m); expect(result).toMatch(/^models:/m); - expect(result).toMatch(/^\s+- uses: /m); - expect(result).toMatch(/^\s+with:/m); - expect(result).toMatch(/^\s+ANTHROPIC_API_KEY: /m); + expect(result).toMatch(/^\s+- name: /m); + expect(result).toMatch(/^\s+provider: /m); + expect(result).toMatch(/^\s+model: /m); + expect(result).toMatch(/^\s+apiKey: /m); }); }); @@ -189,8 +205,9 @@ models: "not an array" const result = updateAnthropicModelInYaml(malformedConfig, testApiKey); - expect(result).toContain("uses: anthropic/claude-sonnet-4-6"); - expect(result).toContain("ANTHROPIC_API_KEY: sk-ant-test123456789"); + expect(result).toContain("provider: anthropic"); + expect(result).toContain("model: claude-sonnet-4-6"); + expect(result).toContain("apiKey: sk-ant-test123456789"); }); it("should handle different API key formats", () => { @@ -202,8 +219,40 @@ models: "not an array" differentKeys.forEach((key) => { const result = updateAnthropicModelInYaml("", key); - expect(result).toContain(`ANTHROPIC_API_KEY: ${key}`); + expect(result).toContain(`apiKey: ${key}`); }); }); }); }); + +describe("updateOpenAIModelInYaml", () => { + const testApiKey = "sk-openai-test123456789"; + + it("should create new config from empty string", () => { + const result = updateOpenAIModelInYaml("", testApiKey); + + expect(result).toContain("name: Local Config"); + expect(result).toContain("provider: openai"); + expect(result).toContain("model: gpt-5.4"); + expect(result).toContain("apiKey: sk-openai-test123456789"); + }); + + it("should update existing openai model", () => { + const existingConfig = `name: Local Config +version: 1.0.0 +schema: v1 +models: + - name: GPT 5.4 + provider: openai + model: gpt-5.4 + apiKey: old-key +`; + + const result = updateOpenAIModelInYaml(existingConfig, testApiKey); + + expect(result).toContain("provider: openai"); + expect(result).toContain("model: gpt-5.4"); + expect(result).toContain("apiKey: sk-openai-test123456789"); + expect(result).not.toContain("old-key"); + }); +}); diff --git a/extensions/cli/src/util/yamlConfigUpdater.ts b/extensions/cli/src/util/yamlConfigUpdater.ts index 08b1c27fb2a..fed256f2990 100644 --- a/extensions/cli/src/util/yamlConfigUpdater.ts +++ b/extensions/cli/src/util/yamlConfigUpdater.ts @@ -1,10 +1,10 @@ import { parseDocument } from "yaml"; export interface ModelConfig { - uses: string; - with: { - ANTHROPIC_API_KEY: string; - }; + name: string; + provider: string; + model: string; + apiKey: string; } export interface ConfigStructure { @@ -27,10 +27,10 @@ export function updateAnthropicModelInYaml( apiKey: string, ): string { const newModel: ModelConfig = { - uses: "anthropic/claude-sonnet-4-6", - with: { - ANTHROPIC_API_KEY: apiKey, - }, + name: "claude sonnet 4.6", + provider: "anthropic", + model: "claude-sonnet-4-6", + apiKey: apiKey, }; try { @@ -62,10 +62,82 @@ export function updateAnthropicModelInYaml( // Filter out existing anthropic models config.models = config.models.filter( - (model: any) => !model || model.uses !== "anthropic/claude-sonnet-4-6", + (model: any) => + !model || + !( + model.provider === "anthropic" && model.model === "claude-sonnet-4-6" + ), + ); + + config.models.push(newModel); + + doc.set("models", config.models); + + return doc.toString(); + } catch { + const defaultConfig: ConfigStructure = { + name: "Local Config", + version: "1.0.0", + schema: "v1", + models: [newModel], + }; + + const doc = parseDocument(""); + Object.keys(defaultConfig).forEach((key) => + doc.set(key, (defaultConfig as any)[key]), + ); + return doc.toString(); + } +} + +/** + * Updates or adds an OpenAI model configuration in a YAML string while preserving comments and formatting. + * This is a pure function that takes a YAML string and returns a modified YAML string. + * + * @param yamlContent - The original YAML content as a string (can be empty) + * @param apiKey - The OpenAI API key to set + * @returns The updated YAML content as a string with comments preserved + */ +export function updateOpenAIModelInYaml( + yamlContent: string, + apiKey: string, +): string { + const newModel: ModelConfig = { + name: "GPT 5.4", + provider: "openai", + model: "gpt-5.4", + apiKey: apiKey, + }; + + try { + const doc = parseDocument(yamlContent); + + if (!doc.contents || doc.contents === null) { + const defaultConfig: ConfigStructure = { + name: "Local Config", + version: "1.0.0", + schema: "v1", + models: [newModel], + }; + + const newDoc = parseDocument(""); + Object.keys(defaultConfig).forEach((key) => + newDoc.set(key, (defaultConfig as any)[key]), + ); + return newDoc.toString(); + } + + const config = doc.toJS() as any; + + if (!config.models) { + config.models = []; + } + + config.models = config.models.filter( + (model: any) => + !model || !(model.provider === "openai" && model.model === "gpt-5.4"), ); - // Add the new anthropic model config.models.push(newModel); // Update the models array while preserving comments and structure