diff --git a/.env.example b/.env.example index 8e82cac3d..ba7d03e8c 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,9 @@ PRODUCTION_TEST_MODE=true TEST_GPT5_API_KEY=your_gpt5_api_key_here TEST_GPT5_BASE_URL=http://127.0.0.1:3000/openai +# OpenRouter Provider Configuration +OPENROUTER_API_KEY=your_openrouter_api_key_here + # MiniMax Codex Test Configuration TEST_MINIMAX_API_KEY=your_minimax_api_key_here TEST_MINIMAX_BASE_URL=https://api.minimaxi.com/v1 diff --git a/README.md b/README.md index 8abf7373b..651a80b0e 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,38 @@ You can use the onboarding to set up the model, or `/model`. If you don't see the models you want on the list, you can manually set them in `/config` As long as you have an openai-like endpoint, it should work. +### OpenRouter setup + +Kode includes OpenRouter as an OpenAI-compatible provider. Create an API key in [OpenRouter](https://openrouter.ai/settings/keys), then choose OpenRouter from `/model` and select or enter any OpenRouter model ID, for example: + +```bash +export OPENROUTER_API_KEY=sk-or-v1-... +``` + +```yaml +version: 1 +profiles: + - name: OpenRouter Claude Sonnet + provider: openrouter + modelName: anthropic/claude-sonnet-4.5 + baseURL: https://openrouter.ai/api/v1 + maxTokens: 8192 + contextLength: 200000 + apiKey: + fromEnv: OPENROUTER_API_KEY +pointers: + main: anthropic/claude-sonnet-4.5 + task: anthropic/claude-sonnet-4.5 + compact: anthropic/claude-sonnet-4.5 + quick: anthropic/claude-sonnet-4.5 +``` + +Import the profile with: + +```bash +kode models import kode-openrouter.yaml +``` + ### Commands - `/help` - Show available commands @@ -516,6 +548,8 @@ pointers: quick: gpt-4o ``` +For OpenRouter, use `provider: openrouter`, `baseURL: https://openrouter.ai/api/v1`, and `apiKey.fromEnv: OPENROUTER_API_KEY`. + #### 2. **TaskTool Intelligent Task Distribution** Our specially designed `TaskTool` (Architect tool) implements: - **Subagent Mechanism**: Can launch multiple sub-agents to process tasks in parallel diff --git a/docs/develop/configuration.md b/docs/develop/configuration.md index 16781f488..1899e8112 100644 --- a/docs/develop/configuration.md +++ b/docs/develop/configuration.md @@ -227,6 +227,7 @@ kode config list -g ```bash # API Keys OPENAI_API_KEY=sk-... +OPENROUTER_API_KEY=sk-or-v1-... # Model Selection CLAUDE_MODEL=claude-3-5-sonnet-20241022 @@ -361,6 +362,30 @@ Temporary for current session: ### Custom Model Providers +#### OpenRouter + +OpenRouter is available as an OpenAI-compatible provider. Use the `/model` selector and choose OpenRouter, or import a model profile: + +```yaml +version: 1 +profiles: + - name: OpenRouter Claude Sonnet + provider: openrouter + modelName: anthropic/claude-sonnet-4.5 + baseURL: https://openrouter.ai/api/v1 + maxTokens: 8192 + contextLength: 200000 + apiKey: + fromEnv: OPENROUTER_API_KEY +pointers: + main: anthropic/claude-sonnet-4.5 + task: anthropic/claude-sonnet-4.5 + compact: anthropic/claude-sonnet-4.5 + quick: anthropic/claude-sonnet-4.5 +``` + +OpenRouter model IDs use the `provider/model` format shown in the [OpenRouter model list](https://openrouter.ai/models). + ```json { "modelProfiles": { diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts index c593ec4ed..24393fcbd 100644 --- a/src/core/config/schema.ts +++ b/src/core/config/schema.ts @@ -107,6 +107,7 @@ export type ProviderType = | 'opendev' | 'xai' | 'groq' + | 'openrouter' | 'gemini' | 'ollama' | 'azure' diff --git a/src/core/config/validator.ts b/src/core/config/validator.ts index 30b83127b..f5c336baf 100644 --- a/src/core/config/validator.ts +++ b/src/core/config/validator.ts @@ -95,22 +95,26 @@ export function validateAndRepairGPT5Profile( if ( profile.provider !== 'openai' && profile.provider !== 'custom-openai' && + profile.provider !== 'openrouter' && profile.provider !== 'azure' ) { debugLogger.warn('GPT5_CONFIG_UNEXPECTED_PROVIDER', { model: profile.modelName, provider: profile.provider, - expectedProviders: ['openai', 'custom-openai', 'azure'], + expectedProviders: ['openai', 'custom-openai', 'openrouter', 'azure'], }) } if (profile.modelName.includes('gpt-5') && !profile.baseURL) { - repairedProfile.baseURL = 'https://api.openai.com/v1' + repairedProfile.baseURL = + profile.provider === 'openrouter' + ? 'https://openrouter.ai/api/v1' + : 'https://api.openai.com/v1' wasRepaired = true debugLogger.state('GPT5_CONFIG_AUTO_REPAIR', { model: profile.modelName, field: 'baseURL', - value: 'https://api.openai.com/v1', + value: repairedProfile.baseURL, }) } } @@ -210,4 +214,3 @@ export function createGPT5ModelProfile( return profile } - diff --git a/src/services/ai/openai.ts b/src/services/ai/openai.ts index b3f52da9c..1238a8b69 100644 --- a/src/services/ai/openai.ts +++ b/src/services/ai/openai.ts @@ -559,6 +559,7 @@ export async function getCompletionWithProfile( 'mistral', 'xai', 'groq', + 'openrouter', 'custom-openai', ].includes(provider) @@ -716,6 +717,7 @@ export async function getCompletionWithProfile( 'mistral', 'xai', 'groq', + 'openrouter', 'custom-openai', ].includes(provider) diff --git a/src/ui/components/model-selector/ModelSelector.tsx b/src/ui/components/model-selector/ModelSelector.tsx index d05002b1c..f9800c5b2 100644 --- a/src/ui/components/model-selector/ModelSelector.tsx +++ b/src/ui/components/model-selector/ModelSelector.tsx @@ -603,6 +603,7 @@ export function ModelSelector({ 'glm', 'minimax', 'baidu-qianfan', + 'openrouter', 'custom-openai', ].includes(selectedProvider) @@ -938,6 +939,7 @@ export function ModelSelector({ 'mistral', 'xai', 'groq', + 'openrouter', 'custom-openai', ].includes(selectedProvider) diff --git a/src/utils/model/modelConfigYaml.ts b/src/utils/model/modelConfigYaml.ts index 2e30055a2..2b521f01d 100644 --- a/src/utils/model/modelConfigYaml.ts +++ b/src/utils/model/modelConfigYaml.ts @@ -63,6 +63,8 @@ function suggestedApiKeyEnvForProvider(provider: string): string | undefined { case 'openai': case 'custom-openai': return 'OPENAI_API_KEY' + case 'openrouter': + return 'OPENROUTER_API_KEY' case 'azure': return 'AZURE_OPENAI_API_KEY' case 'gemini': diff --git a/tests/unit/config-validator-openrouter.test.ts b/tests/unit/config-validator-openrouter.test.ts new file mode 100644 index 000000000..eded1e5b9 --- /dev/null +++ b/tests/unit/config-validator-openrouter.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from 'bun:test' +import { validateAndRepairGPT5Profile } from '../../src/core/config/validator' + +describe('OpenRouter GPT-5 config validation', () => { + test('repairs missing GPT-5 baseURL to OpenRouter for OpenRouter profiles', () => { + const repaired = validateAndRepairGPT5Profile({ + name: 'OpenRouter GPT-5', + provider: 'openrouter', + modelName: 'openai/gpt-5', + apiKey: 'test-key', + maxTokens: 8192, + contextLength: 128000, + isActive: true, + createdAt: 1, + }) + + expect(repaired.baseURL).toBe('https://openrouter.ai/api/v1') + expect(repaired.validationStatus).toBe('auto_repaired') + }) +}) diff --git a/tests/unit/model-config-yaml.test.ts b/tests/unit/model-config-yaml.test.ts index b254c4264..4da5fbe10 100644 --- a/tests/unit/model-config-yaml.test.ts +++ b/tests/unit/model-config-yaml.test.ts @@ -48,6 +48,37 @@ describe('modelConfigYaml', () => { expect(yamlText).toContain('fromEnv') }) + test('export uses OPENROUTER_API_KEY for OpenRouter profiles', () => { + const config: any = { + modelProfiles: [ + { + name: 'OpenRouter Main', + provider: 'openrouter', + modelName: 'anthropic/claude-sonnet-4.5', + baseURL: 'https://openrouter.ai/api/v1', + apiKey: 'SECRET_KEY_SHOULD_NOT_APPEAR', + maxTokens: 8192, + contextLength: 200000, + isActive: true, + createdAt: 1, + }, + ], + modelPointers: { + main: 'anthropic/claude-sonnet-4.5', + task: 'anthropic/claude-sonnet-4.5', + compact: 'anthropic/claude-sonnet-4.5', + quick: 'anthropic/claude-sonnet-4.5', + }, + } + + const yamlText = formatModelConfigYamlForSharing(config) + + expect(yamlText).toContain('provider: openrouter') + expect(yamlText).toContain('baseURL: https://openrouter.ai/api/v1') + expect(yamlText).toContain('fromEnv: OPENROUTER_API_KEY') + expect(yamlText).not.toContain('SECRET_KEY_SHOULD_NOT_APPEAR') + }) + test('import resolves apiKey from env and applies pointers', () => { process.env.TEST_OPENAI_KEY = 'resolved-from-env'