Skip to content

Commit 1db80df

Browse files
author
root
committed
feat: add hybrid MAX + API key auth support
- New auth config: anthropicMaxToken, useMaxForModels - Routes Opus to MAX token, other models to API key - Enables MAX users to save money on simple tasks - Logs which auth method is used v0.1.9
1 parent b2feb0a commit 1db80df

File tree

4 files changed

+124
-26
lines changed

4 files changed

+124
-26
lines changed

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,25 @@ Use Opus for code review with fallback:
297297
}
298298
```
299299

300+
### Hybrid Auth (MAX + API Key)
301+
302+
If you have a MAX subscription, you can use MAX for Opus and API keys for cheaper models:
303+
304+
```json
305+
{
306+
"auth": {
307+
"anthropicMaxToken": "your-max-token-here",
308+
"useMaxForModels": ["opus"]
309+
}
310+
}
311+
```
312+
313+
This routes:
314+
- **Opus requests** → MAX token (already paid for)
315+
- **Haiku/Sonnet requests** → API key (pay per token)
316+
317+
Best of both worlds: unlimited Opus via MAX, cheap Haiku for simple tasks.
318+
300319
## Data Storage
301320

302321
All data stored locally at `~/.relayplane/data.db` (SQLite).

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@relayplane/proxy",
3-
"version": "0.1.8",
3+
"version": "0.1.9",
44
"description": "RelayPlane Proxy - Intelligent AI model routing for cost optimization. 100% local, zero cloud dependency.",
55
"main": "./dist/index.js",
66
"module": "./dist/index.mjs",

src/config.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,15 @@ const StrategySchema = z.object({
2121
fallback: z.string().optional(),
2222
});
2323

24+
/**
25+
* Auth configuration for hybrid API key / MAX token support
26+
*/
27+
const AuthSchema = z.object({
28+
anthropicApiKey: z.string().optional(),
29+
anthropicMaxToken: z.string().optional(),
30+
useMaxForModels: z.array(z.string()).optional(), // Default: ['opus']
31+
}).optional();
32+
2433
/**
2534
* Full config schema
2635
*/
@@ -30,6 +39,7 @@ const ConfigSchema = z.object({
3039
qualityModel: z.string().optional(),
3140
costModel: z.string().optional(),
3241
}).optional(),
42+
auth: AuthSchema,
3343
});
3444

3545
export type StrategyConfig = z.infer<typeof StrategySchema>;
@@ -118,6 +128,34 @@ export function getStrategy(config: Config, taskType: TaskType): StrategyConfig
118128
return config.strategies?.[taskType] ?? null;
119129
}
120130

131+
/**
132+
* Determine which Anthropic auth to use based on model
133+
* Returns: { type: 'apiKey' | 'max', value: string } or null if no auth configured
134+
*/
135+
export function getAnthropicAuth(
136+
config: Config,
137+
model: string
138+
): { type: 'apiKey' | 'max'; value: string } | null {
139+
const auth = config.auth;
140+
141+
// Check if this model should use MAX
142+
const useMaxForModels = auth?.useMaxForModels ?? ['opus'];
143+
const shouldUseMax = useMaxForModels.some(m => model.toLowerCase().includes(m.toLowerCase()));
144+
145+
// If MAX token configured and model matches, use MAX
146+
if (shouldUseMax && auth?.anthropicMaxToken) {
147+
return { type: 'max', value: auth.anthropicMaxToken };
148+
}
149+
150+
// Otherwise use API key from config or env
151+
const apiKey = auth?.anthropicApiKey ?? process.env['ANTHROPIC_API_KEY'];
152+
if (apiKey) {
153+
return { type: 'apiKey', value: apiKey };
154+
}
155+
156+
return null;
157+
}
158+
121159
/**
122160
* Watch config file for changes
123161
*/

src/proxy.ts

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ import * as http from 'node:http';
1717
import * as url from 'node:url';
1818
import { RelayPlane } from './relay.js';
1919
import { inferTaskType, getInferenceConfidence } from './routing/inference.js';
20-
import { loadConfig, watchConfig, getStrategy, type Config } from './config.js';
20+
import { loadConfig, watchConfig, getStrategy, getAnthropicAuth, type Config } from './config.js';
2121
import type { Provider, TaskType } from './types.js';
2222

2323
/** Package version */
24-
const VERSION = '0.1.8';
24+
const VERSION = '0.1.9';
2525

2626
/** Recent runs buffer for /runs endpoint */
2727
interface RecentRun {
@@ -164,23 +164,37 @@ function extractPromptText(messages: ChatRequest['messages']): string {
164164
.join('\n');
165165
}
166166

167+
/**
168+
* Auth info for Anthropic requests
169+
*/
170+
interface AnthropicAuth {
171+
type: 'apiKey' | 'max';
172+
value: string;
173+
}
174+
167175
/**
168176
* Forward non-streaming request to Anthropic API
169177
*/
170178
async function forwardToAnthropic(
171179
request: ChatRequest,
172180
targetModel: string,
173-
apiKey: string,
181+
auth: AnthropicAuth,
174182
betaHeaders?: string
175183
): Promise<Response> {
176184
const anthropicBody = buildAnthropicBody(request, targetModel, false);
177185

178186
const headers: Record<string, string> = {
179187
'Content-Type': 'application/json',
180-
'x-api-key': apiKey,
181188
'anthropic-version': '2023-06-01',
182189
};
183190

191+
// Use appropriate auth header based on type
192+
if (auth.type === 'max') {
193+
headers['Authorization'] = `Bearer ${auth.value}`;
194+
} else {
195+
headers['x-api-key'] = auth.value;
196+
}
197+
184198
// Pass through beta headers (prompt caching, extended thinking, etc.)
185199
if (betaHeaders) {
186200
headers['anthropic-beta'] = betaHeaders;
@@ -201,17 +215,23 @@ async function forwardToAnthropic(
201215
async function forwardToAnthropicStream(
202216
request: ChatRequest,
203217
targetModel: string,
204-
apiKey: string,
218+
auth: AnthropicAuth,
205219
betaHeaders?: string
206220
): Promise<Response> {
207221
const anthropicBody = buildAnthropicBody(request, targetModel, true);
208222

209223
const headers: Record<string, string> = {
210224
'Content-Type': 'application/json',
211-
'x-api-key': apiKey,
212225
'anthropic-version': '2023-06-01',
213226
};
214227

228+
// Use appropriate auth header based on type
229+
if (auth.type === 'max') {
230+
headers['Authorization'] = `Bearer ${auth.value}`;
231+
} else {
232+
headers['x-api-key'] = auth.value;
233+
}
234+
215235
if (betaHeaders) {
216236
headers['anthropic-beta'] = betaHeaders;
217237
}
@@ -1419,14 +1439,29 @@ export async function startProxy(config: ProxyConfig = {}): Promise<http.Server>
14191439

14201440
log(`Routing to: ${targetProvider}/${targetModel}`);
14211441

1422-
// Get API key for target provider
1423-
const apiKeyEnv = DEFAULT_ENDPOINTS[targetProvider]?.apiKeyEnv ?? `${targetProvider.toUpperCase()}_API_KEY`;
1424-
const apiKey = process.env[apiKeyEnv];
1442+
// Get auth for target provider
1443+
let apiKey: string | undefined;
1444+
let anthropicAuth: { type: 'apiKey' | 'max'; value: string } | null = null;
14251445

1426-
if (!apiKey) {
1427-
res.writeHead(500, { 'Content-Type': 'application/json' });
1428-
res.end(JSON.stringify({ error: `Missing ${apiKeyEnv} environment variable` }));
1429-
return;
1446+
if (targetProvider === 'anthropic') {
1447+
// Use hybrid auth system for Anthropic (supports MAX + API key)
1448+
anthropicAuth = getAnthropicAuth(currentConfig, targetModel);
1449+
if (!anthropicAuth) {
1450+
res.writeHead(500, { 'Content-Type': 'application/json' });
1451+
res.end(JSON.stringify({ error: 'No Anthropic auth configured (set ANTHROPIC_API_KEY or config.auth.anthropicMaxToken)' }));
1452+
return;
1453+
}
1454+
log(`Using ${anthropicAuth.type === 'max' ? 'MAX token' : 'API key'} auth for ${targetModel}`);
1455+
} else {
1456+
// Standard API key auth for other providers
1457+
const apiKeyEnv = DEFAULT_ENDPOINTS[targetProvider]?.apiKeyEnv ?? `${targetProvider.toUpperCase()}_API_KEY`;
1458+
apiKey = process.env[apiKeyEnv];
1459+
1460+
if (!apiKey) {
1461+
res.writeHead(500, { 'Content-Type': 'application/json' });
1462+
res.end(JSON.stringify({ error: `Missing ${apiKeyEnv} environment variable` }));
1463+
return;
1464+
}
14301465
}
14311466

14321467
const startTime = Date.now();
@@ -1442,6 +1477,7 @@ export async function startProxy(config: ProxyConfig = {}): Promise<http.Server>
14421477
targetProvider,
14431478
targetModel,
14441479
apiKey,
1480+
anthropicAuth,
14451481
relay,
14461482
promptText,
14471483
taskType,
@@ -1458,6 +1494,7 @@ export async function startProxy(config: ProxyConfig = {}): Promise<http.Server>
14581494
targetProvider,
14591495
targetModel,
14601496
apiKey,
1497+
anthropicAuth,
14611498
relay,
14621499
promptText,
14631500
taskType,
@@ -1499,7 +1536,8 @@ async function handleStreamingRequest(
14991536
request: ChatRequest,
15001537
targetProvider: Provider,
15011538
targetModel: string,
1502-
apiKey: string,
1539+
apiKey: string | undefined,
1540+
anthropicAuth: { type: 'apiKey' | 'max'; value: string } | null,
15031541
relay: RelayPlane,
15041542
promptText: string,
15051543
taskType: TaskType,
@@ -1514,19 +1552,20 @@ async function handleStreamingRequest(
15141552
try {
15151553
switch (targetProvider) {
15161554
case 'anthropic':
1517-
providerResponse = await forwardToAnthropicStream(request, targetModel, apiKey, betaHeaders);
1555+
if (!anthropicAuth) throw new Error('No Anthropic auth');
1556+
providerResponse = await forwardToAnthropicStream(request, targetModel, anthropicAuth, betaHeaders);
15181557
break;
15191558
case 'google':
1520-
providerResponse = await forwardToGeminiStream(request, targetModel, apiKey);
1559+
providerResponse = await forwardToGeminiStream(request, targetModel, apiKey!);
15211560
break;
15221561
case 'xai':
1523-
providerResponse = await forwardToXAIStream(request, targetModel, apiKey);
1562+
providerResponse = await forwardToXAIStream(request, targetModel, apiKey!);
15241563
break;
15251564
case 'moonshot':
1526-
providerResponse = await forwardToMoonshotStream(request, targetModel, apiKey);
1565+
providerResponse = await forwardToMoonshotStream(request, targetModel, apiKey!);
15271566
break;
15281567
default:
1529-
providerResponse = await forwardToOpenAIStream(request, targetModel, apiKey);
1568+
providerResponse = await forwardToOpenAIStream(request, targetModel, apiKey!);
15301569
}
15311570

15321571
if (!providerResponse.ok) {
@@ -1619,7 +1658,8 @@ async function handleNonStreamingRequest(
16191658
request: ChatRequest,
16201659
targetProvider: Provider,
16211660
targetModel: string,
1622-
apiKey: string,
1661+
apiKey: string | undefined,
1662+
anthropicAuth: { type: 'apiKey' | 'max'; value: string } | null,
16231663
relay: RelayPlane,
16241664
promptText: string,
16251665
taskType: TaskType,
@@ -1635,7 +1675,8 @@ async function handleNonStreamingRequest(
16351675
try {
16361676
switch (targetProvider) {
16371677
case 'anthropic': {
1638-
providerResponse = await forwardToAnthropic(request, targetModel, apiKey, betaHeaders);
1678+
if (!anthropicAuth) throw new Error('No Anthropic auth');
1679+
providerResponse = await forwardToAnthropic(request, targetModel, anthropicAuth, betaHeaders);
16391680
const rawData = (await providerResponse.json()) as AnthropicResponse;
16401681

16411682
if (!providerResponse.ok) {
@@ -1649,7 +1690,7 @@ async function handleNonStreamingRequest(
16491690
break;
16501691
}
16511692
case 'google': {
1652-
providerResponse = await forwardToGemini(request, targetModel, apiKey);
1693+
providerResponse = await forwardToGemini(request, targetModel, apiKey!);
16531694
const rawData = (await providerResponse.json()) as GeminiResponse;
16541695

16551696
if (!providerResponse.ok) {
@@ -1663,7 +1704,7 @@ async function handleNonStreamingRequest(
16631704
break;
16641705
}
16651706
case 'xai': {
1666-
providerResponse = await forwardToXAI(request, targetModel, apiKey);
1707+
providerResponse = await forwardToXAI(request, targetModel, apiKey!);
16671708
responseData = (await providerResponse.json()) as Record<string, unknown>;
16681709

16691710
if (!providerResponse.ok) {
@@ -1674,7 +1715,7 @@ async function handleNonStreamingRequest(
16741715
break;
16751716
}
16761717
case 'moonshot': {
1677-
providerResponse = await forwardToMoonshot(request, targetModel, apiKey);
1718+
providerResponse = await forwardToMoonshot(request, targetModel, apiKey!);
16781719
responseData = (await providerResponse.json()) as Record<string, unknown>;
16791720

16801721
if (!providerResponse.ok) {
@@ -1685,7 +1726,7 @@ async function handleNonStreamingRequest(
16851726
break;
16861727
}
16871728
default: {
1688-
providerResponse = await forwardToOpenAI(request, targetModel, apiKey);
1729+
providerResponse = await forwardToOpenAI(request, targetModel, apiKey!);
16891730
responseData = (await providerResponse.json()) as Record<string, unknown>;
16901731

16911732
if (!providerResponse.ok) {

0 commit comments

Comments
 (0)