Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dashboard/src/config/providers/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const anthropic: ProviderConfig = {
id: 'anthropic',
label: 'Anthropic',
baseUrl: 'https://api.anthropic.com/v1',
keyHint: 'sk-ant-...',
defaultModel: 'claude-opus-4-5',
models: [
{ id: 'claude-opus-4-5', label: 'Claude Opus 4.5' },
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/config/providers/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const google: ProviderConfig = {
id: 'google',
label: 'Google',
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
keyHint: 'AIza...',
defaultModel: 'gemini-3-pro-preview',
models: [
{ id: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
Expand Down
7 changes: 6 additions & 1 deletion dashboard/src/config/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { openai } from './openai';
import { anthropic } from './anthropic';
import { google } from './google';
import { venice } from './venice';
import { openrouter } from './openrouter';

export type { ProviderConfig, ModelInfo };

export const PROVIDERS: ProviderConfig[] = [openai, anthropic, google, venice];
export const PROVIDERS: ProviderConfig[] = [openai, anthropic, google, venice, openrouter];

export const AI_PROVIDERS = PROVIDERS.map((p) => ({
value: p.id,
Expand All @@ -29,3 +30,7 @@ export function getDefaultModel(providerId: string): string {
const provider = getProvider(providerId);
return provider?.defaultModel ?? provider?.models[0]?.id ?? '';
}

export function getKeyHint(providerId: string): string {
return getProvider(providerId)?.keyHint ?? 'API key';
}
1 change: 1 addition & 0 deletions dashboard/src/config/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const openai: ProviderConfig = {
id: 'openai',
label: 'OpenAI',
baseUrl: 'https://api.openai.com/v1',
keyHint: 'sk-...',
defaultModel: 'gpt-5.2',
models: [
{ id: 'gpt-5.2', label: 'GPT-5.2' },
Expand Down
17 changes: 17 additions & 0 deletions dashboard/src/config/providers/openrouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { ProviderConfig } from './types';

export const openrouter: ProviderConfig = {
id: 'openrouter',
label: 'OpenRouter',
baseUrl: 'https://openrouter.ai/api/v1',
keyHint: 'sk-or-...',
defaultModel: 'anthropic/claude-sonnet-4-5',
models: [
{ id: 'openrouter/auto', label: 'Auto (best for task)' },
{ id: 'anthropic/claude-opus-4-5', label: 'Claude Opus 4.5' },
{ id: 'anthropic/claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
{ id: 'openai/gpt-5.2', label: 'GPT-5.2' },
{ id: 'google/gemini-3-pro', label: 'Gemini 3 Pro' },
{ id: 'deepseek/deepseek-v3.2', label: 'DeepSeek V3.2' },
],
};
1 change: 1 addition & 0 deletions dashboard/src/config/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ export interface ProviderConfig {
baseUrl: string;
models: ModelInfo[];
defaultModel: string;
keyHint?: string; // Placeholder hint for API key format (e.g., "sk-ant-...")
}
2 changes: 2 additions & 0 deletions dashboard/src/config/providers/venice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export const venice: ProviderConfig = {
id: 'venice',
label: 'Venice',
baseUrl: 'https://api.venice.ai/api/v1',
keyHint: 'VENICE-INFERENCE-KEY-...',
defaultModel: 'llama-3.3-70b',
models: [
{ id: 'llama-3.3-70b', label: 'Llama 3.3 70B' },
Expand All @@ -12,5 +13,6 @@ export const venice: ProviderConfig = {
{ id: 'deepseek-v3.2', label: 'DeepSeek V3.2' },
{ id: 'zai-org-glm-4.7', label: 'ZAI GLM 4.7' },
{ id: 'mistral-31-24b', label: 'Mistral 31 24B' },
{ id: 'kimi-k2-5', label: 'Kimi K2.5' },
],
};
4 changes: 2 additions & 2 deletions dashboard/src/secrets/AddKeyForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useState } from 'react';
import type { AddKeyInput } from '../types';
import { PROVIDERS } from '../config/providers';
import { PROVIDERS, getKeyHint } from '../config/providers';
import './AddKeyForm.css';

interface AddKeyFormProps {
Expand Down Expand Up @@ -73,7 +73,7 @@ export function AddKeyForm({ onSubmit, onCancel, loading }: AddKeyFormProps) {
type="password"
value={secret}
onChange={(e) => { setSecret(e.target.value); }}
placeholder="sk-..."
placeholder={getKeyHint(vendor)}
disabled={loading}
autoComplete="off"
/>
Expand Down
15 changes: 11 additions & 4 deletions proxy/src/routes/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,21 @@ export function registerProxyRoutes(
}
const vendorConfig = VENDOR_CONFIGS[vendor];

// Extract and validate bot token
// Extract bot token from either Authorization header or x-api-key
// This supports both OpenAI-style (Bearer token) and Anthropic-style (x-api-key) auth
let botToken: string | undefined;

const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
if (auth?.startsWith('Bearer ')) {
botToken = auth.slice(7);
} else if (req.headers['x-api-key'] && typeof req.headers['x-api-key'] === 'string') {
botToken = req.headers['x-api-key'];
}

if (!botToken) {
reply.status(401).send({ error: 'Missing authorization' });
return;
}

const botToken = auth.slice(7);
const tokenHash = hashToken(botToken);

// Lookup bot
Expand Down
8 changes: 7 additions & 1 deletion proxy/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const VENDOR_CONFIGS: Record<string, VendorConfig> = {
},
anthropic: {
host: 'api.anthropic.com',
basePath: '/v1',
basePath: '', // OpenClaw's anthropic-messages API includes /v1 in its path
authHeader: 'x-api-key',
authFormat: (key) => key,
},
Expand All @@ -56,4 +56,10 @@ export const VENDOR_CONFIGS: Record<string, VendorConfig> = {
authHeader: 'x-goog-api-key',
authFormat: (key) => key,
},
openrouter: {
host: 'openrouter.ai',
basePath: '/api/v1',
authHeader: 'Authorization',
authFormat: (key) => `Bearer ${key}`,
},
};
46 changes: 46 additions & 0 deletions src/bots/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,52 @@ describe('templates', () => {
expect(model.primary).toBe('anthropic/claude-3-opus');
});

it('should use anthropic-messages API type for anthropic with proxy', () => {
const config = createTestConfig({
aiProvider: 'anthropic',
model: 'claude-3-opus',
proxy: {
baseUrl: 'http://proxy:9101/v1/anthropic',
token: 'proxy-token-123',
},
});
createBotWorkspace(testDir, config);

const openclawPath = join(testDir, 'bots', config.botHostname, 'openclaw.json');
const openclawConfig = JSON.parse(readFileSync(openclawPath, 'utf-8')) as Record<string, unknown>;

expect(openclawConfig.models).toEqual({
providers: {
'anthropic-proxy': {
baseUrl: 'http://proxy:9101/v1/anthropic',
apiKey: 'proxy-token-123',
api: 'anthropic-messages',
models: [{ id: 'claude-3-opus', name: 'claude-3-opus' }],
},
},
});
});

it('should use openai-completions API type for venice with proxy', () => {
const config = createTestConfig({
aiProvider: 'venice',
model: 'llama-3.3-70b',
proxy: {
baseUrl: 'http://proxy:9101/v1/venice',
token: 'proxy-token-123',
},
});
createBotWorkspace(testDir, config);

const openclawPath = join(testDir, 'bots', config.botHostname, 'openclaw.json');
const openclawConfig = JSON.parse(readFileSync(openclawPath, 'utf-8')) as Record<string, unknown>;
const models = openclawConfig.models as Record<string, unknown>;
const providers = models.providers as Record<string, unknown>;
const veniceProxy = providers['venice-proxy'] as Record<string, unknown>;

expect(veniceProxy.api).toBe('openai-completions');
});

it('should include persona in workspace files', () => {
const config = createTestConfig({
persona: {
Expand Down
21 changes: 20 additions & 1 deletion src/bots/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,25 @@ export interface BotWorkspaceConfig {
proxy?: ProxyConfig;
}

/**
* Map AI provider to OpenClaw API type.
* Each provider uses a different API format that OpenClaw must know about.
*/
function getApiTypeForProvider(provider: string): string {
switch (provider) {
case 'anthropic':
return 'anthropic-messages';
case 'google':
return 'google-gemini';
case 'venice':
case 'openrouter':
return 'openai-completions'; // OpenAI-compatible APIs
case 'openai':
default:
return 'openai-responses';
}
}

/**
* Generate openclaw.json configuration.
* Follows OpenClaw's expected config structure for gateway mode.
Expand All @@ -72,7 +91,7 @@ function generateOpenclawConfig(config: BotWorkspaceConfig): object {
[`${config.aiProvider}-proxy`]: {
baseUrl: config.proxy.baseUrl,
apiKey: config.proxy.token,
api: 'openai-responses', // Required for custom providers
api: getApiTypeForProvider(config.aiProvider),
models: [{ id: config.model, name: config.model }],
},
},
Expand Down