Skip to content

Commit 3a67166

Browse files
brovenclaude
andauthored
feat: Add OpenAI format provider support (#11)
* feat: add OpenAI format provider support Add bidirectional format conversion for OpenAI Chat Completions API, enabling fallback providers like OpenRouter, Together AI, and any OpenAI-compatible endpoint. Changes: - Add format field to ProviderConfig (anthropic|openai) - Implement request/response conversion with tool_use support - Add streaming SSE conversion via TransformStream - Update admin UI with format selector - Add 37 comprehensive tests (259 total passing) - Validate format field at API boundary Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * test: improve format-converter coverage to 99%+ Add tests for edge cases: non-string/non-array content, invalid JSON in stream, interleaved text+tool_calls, flush with remaining buffer, and usage in regular chunks. * chore: add Codecov configuration with relaxed thresholds Allow coverage to decrease by up to 5% and set patch target to 70% to prevent CI failures on minor coverage fluctuations. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: test multiple Claude models in testConnection with mapping suggestions Refactor testProvider to test 4 models (Sonnet, Opus 4, Opus 4.6, Haiku) in parallel instead of a single hardcoded model. Shows per-model results in the admin UI and suggests adding model mappings when models fail without one configured. Also adds claude-opus-4-6-20250415 to CLAUDE_MODELS. * feat: add provider disable/enable toggle in admin panel Show Anthropic primary API as fixed first entry with toggle switch. Replace reorder arrow buttons with enable/disable toggles on all providers. Disabled providers are skipped during request routing. * test: add tests for provider disable/enable skipping behavior Verify disabled Anthropic primary and fallback providers are skipped during request routing, and that 502 is returned when all are disabled. --------- Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
1 parent 3aba47d commit 3a67166

15 files changed

Lines changed: 2204 additions & 167 deletions

.codecov.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# .codecov.yml - 宽松版本
2+
coverage:
3+
status:
4+
project:
5+
default:
6+
target: auto # 自动目标(不会因覆盖率下降而失败)
7+
threshold: 5% # 允许覆盖率下降 5%
8+
patch:
9+
default:
10+
target: 70% # 新代码至少 70% 覆盖率
11+
12+
comment:
13+
layout: "reach,diff,flags,tree"
14+
behavior: default
15+
require_changes: false

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
A fallback proxy for Claude Code (or any Anthropic API client). When you hit rate limits or API errors, automatically routes to alternative providers.
66
just like [vercel](https://vercel.com/changelog/claude-code-max-via-ai-gateway-available-now-for-claude-code) and [openreouter](https://openrouter.ai/docs/guides/guides/claude-code-integration) does
77

8+
[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/broven/claude-code-fallback)
89
## Why This Exists
910

1011
When using Claude Code or other Anthropic API clients, you might encounter:

src/__tests__/admin-coverage.test.ts

Lines changed: 201 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -302,11 +302,15 @@ describe('testProvider success and error paths', () => {
302302
});
303303

304304
const response = await app.fetch(request, env);
305-
const data = await response.json() as { success: boolean; message: string };
305+
const data = await response.json() as any;
306306

307307
expect(response.status).toBe(200);
308308
expect(data.success).toBe(true);
309-
expect(data.message).toContain('200');
309+
expect(data.results).toHaveLength(4);
310+
data.results.forEach((r: any) => {
311+
expect(r.success).toBe(true);
312+
expect(r.message).toContain('200');
313+
});
310314

311315
globalThis.fetch = originalFetch;
312316
});
@@ -332,21 +336,25 @@ describe('testProvider success and error paths', () => {
332336
});
333337

334338
const response = await app.fetch(request, env);
335-
const data = await response.json() as { success: boolean; message: string };
339+
const data = await response.json() as any;
336340

337341
expect(response.status).toBe(200);
338342
expect(data.success).toBe(true);
339-
expect(data.message).toContain('201');
343+
expect(data.results).toHaveLength(4);
344+
data.results.forEach((r: any) => {
345+
expect(r.success).toBe(true);
346+
expect(r.message).toContain('201');
347+
});
340348

341349
globalThis.fetch = originalFetch;
342350
});
343351

344352
it('handles HTTP error with JSON error message', async () => {
345-
const mockFetch = vi.fn().mockResolvedValue(
346-
new Response(JSON.stringify({ error: { message: 'Invalid API key' } }), {
353+
const mockFetch = vi.fn().mockImplementation(() =>
354+
Promise.resolve(new Response(JSON.stringify({ error: { message: 'Invalid API key' } }), {
347355
status: 401,
348356
headers: { 'Content-Type': 'application/json' },
349-
})
357+
}))
350358
);
351359
globalThis.fetch = mockFetch as any;
352360

@@ -362,21 +370,24 @@ describe('testProvider success and error paths', () => {
362370
});
363371

364372
const response = await app.fetch(request, env);
365-
const data = await response.json() as { success: boolean; error: string };
373+
const data = await response.json() as any;
366374

367375
expect(response.status).toBe(200);
368376
expect(data.success).toBe(false);
369-
expect(data.error).toContain('Invalid API key');
377+
data.results.forEach((r: any) => {
378+
expect(r.success).toBe(false);
379+
expect(r.error).toContain('Invalid API key');
380+
});
370381

371382
globalThis.fetch = originalFetch;
372383
});
373384

374385
it('handles HTTP error with plain text response (short)', async () => {
375-
const mockFetch = vi.fn().mockResolvedValue(
376-
new Response('Unauthorized', {
386+
const mockFetch = vi.fn().mockImplementation(() =>
387+
Promise.resolve(new Response('Unauthorized', {
377388
status: 401,
378389
headers: { 'Content-Type': 'text/plain' },
379-
})
390+
}))
380391
);
381392
globalThis.fetch = mockFetch as any;
382393

@@ -392,22 +403,25 @@ describe('testProvider success and error paths', () => {
392403
});
393404

394405
const response = await app.fetch(request, env);
395-
const data = await response.json() as { success: boolean; error: string };
406+
const data = await response.json() as any;
396407

397408
expect(response.status).toBe(200);
398409
expect(data.success).toBe(false);
399-
expect(data.error).toContain('Unauthorized');
410+
data.results.forEach((r: any) => {
411+
expect(r.success).toBe(false);
412+
expect(r.error).toContain('Unauthorized');
413+
});
400414

401415
globalThis.fetch = originalFetch;
402416
});
403417

404418
it('handles HTTP error with long text response (truncated)', async () => {
405419
const longText = 'A'.repeat(250);
406-
const mockFetch = vi.fn().mockResolvedValue(
407-
new Response(longText, {
420+
const mockFetch = vi.fn().mockImplementation(() =>
421+
Promise.resolve(new Response(longText, {
408422
status: 500,
409423
headers: { 'Content-Type': 'text/html' },
410-
})
424+
}))
411425
);
412426
globalThis.fetch = mockFetch as any;
413427

@@ -423,12 +437,15 @@ describe('testProvider success and error paths', () => {
423437
});
424438

425439
const response = await app.fetch(request, env);
426-
const data = await response.json() as { success: boolean; error: string };
440+
const data = await response.json() as any;
427441

428442
expect(response.status).toBe(200);
429443
expect(data.success).toBe(false);
430-
expect(data.error).toContain('HTTP 500');
431-
expect(data.error).not.toContain(longText); // Should not include long text
444+
data.results.forEach((r: any) => {
445+
expect(r.success).toBe(false);
446+
expect(r.error).toContain('HTTP 500');
447+
expect(r.error).not.toContain(longText);
448+
});
432449

433450
globalThis.fetch = originalFetch;
434451
});
@@ -540,9 +557,13 @@ describe('testProvider success and error paths', () => {
540557
});
541558

542559
it('applies model mapping when configured', async () => {
543-
const mockFetch = vi.fn().mockResolvedValue(
544-
new Response(JSON.stringify({ id: 'msg_123' }), { status: 200 })
545-
);
560+
const fetchBodies: any[] = [];
561+
const mockFetch = vi.fn().mockImplementation((_url: string, options: any) => {
562+
fetchBodies.push(JSON.parse(options.body));
563+
return Promise.resolve(
564+
new Response(JSON.stringify({ id: 'msg_123' }), { status: 200 })
565+
);
566+
});
546567
globalThis.fetch = mockFetch as any;
547568

548569
const env = createMockBindings({ adminToken: 'test-token' });
@@ -560,15 +581,23 @@ describe('testProvider success and error paths', () => {
560581
});
561582

562583
const response = await app.fetch(request, env);
563-
const data = await response.json() as { success: boolean };
584+
const data = await response.json() as any;
564585

565586
expect(data.success).toBe(true);
566-
expect(mockFetch).toHaveBeenCalledWith(
567-
expect.any(String),
568-
expect.objectContaining({
569-
body: expect.stringContaining('anthropic/claude-sonnet-4'),
570-
})
571-
);
587+
const models = fetchBodies.map((b) => b.model);
588+
expect(models).toContain('anthropic/claude-sonnet-4');
589+
// Unmapped models use original IDs
590+
expect(models).toContain('claude-opus-4-20250514');
591+
expect(models).toContain('claude-3-5-haiku-20241022');
592+
593+
// Check mappedTo in results
594+
const sonnetResult = data.results.find((r: any) => r.model === 'claude-sonnet-4-20250514');
595+
expect(sonnetResult.mappedTo).toBe('anthropic/claude-sonnet-4');
596+
expect(sonnetResult.hasMappingConfigured).toBe(true);
597+
598+
const opusResult = data.results.find((r: any) => r.model === 'claude-opus-4-20250514');
599+
expect(opusResult.mappedTo).toBeUndefined();
600+
expect(opusResult.hasMappingConfigured).toBe(false);
572601

573602
globalThis.fetch = originalFetch;
574603
});
@@ -589,18 +618,21 @@ describe('testProvider success and error paths', () => {
589618
});
590619

591620
const response = await app.fetch(request, env);
592-
const data = await response.json() as { success: boolean; error: string };
621+
const data = await response.json() as any;
593622

594623
expect(response.status).toBe(200);
595624
expect(data.success).toBe(false);
596-
expect(data.error).toContain('Network error');
625+
data.results.forEach((r: any) => {
626+
expect(r.success).toBe(false);
627+
expect(r.error).toContain('Network error');
628+
});
597629

598630
globalThis.fetch = originalFetch;
599631
});
600632

601633
it('handles timeout (AbortError)', async () => {
602634
const mockFetch = vi.fn().mockImplementation(() => {
603-
return new Promise((resolve, reject) => {
635+
return new Promise((_resolve, reject) => {
604636
setTimeout(() => {
605637
const error = new Error('The operation was aborted');
606638
error.name = 'AbortError';
@@ -622,12 +654,146 @@ describe('testProvider success and error paths', () => {
622654
});
623655

624656
const response = await app.fetch(request, env);
625-
const data = await response.json() as { success: boolean; error: string };
657+
const data = await response.json() as any;
626658

627659
expect(response.status).toBe(200);
628660
expect(data.success).toBe(false);
629-
expect(data.error).toContain('timed out');
661+
data.results.forEach((r: any) => {
662+
expect(r.success).toBe(false);
663+
expect(r.error).toContain('timed out');
664+
});
630665

631666
globalThis.fetch = originalFetch;
632667
}, 15000); // Increase timeout for this test
668+
669+
it('returns results array with 4 models', async () => {
670+
const mockFetch = vi.fn().mockResolvedValue(
671+
new Response(JSON.stringify({ id: 'msg_test' }), { status: 200 })
672+
);
673+
globalThis.fetch = mockFetch as any;
674+
675+
const env = createMockBindings({ adminToken: 'test-token' });
676+
const request = createRequest('/admin/test-provider', {
677+
method: 'POST',
678+
token: 'test-token',
679+
body: {
680+
name: 'test-provider',
681+
baseUrl: 'https://api.example.com/v1/messages',
682+
apiKey: 'sk-test-key',
683+
},
684+
});
685+
686+
const response = await app.fetch(request, env);
687+
const data = await response.json() as any;
688+
689+
expect(data.success).toBe(true);
690+
expect(data.results).toHaveLength(4);
691+
expect(data.results.map((r: any) => r.model)).toEqual([
692+
'claude-sonnet-4-20250514',
693+
'claude-opus-4-20250514',
694+
'claude-opus-4-6-20250415',
695+
'claude-3-5-haiku-20241022',
696+
]);
697+
data.results.forEach((r: any) => {
698+
expect(r.success).toBe(true);
699+
expect(r.label).toBeDefined();
700+
});
701+
702+
globalThis.fetch = originalFetch;
703+
});
704+
705+
it('reports per-model failures independently', async () => {
706+
let callCount = 0;
707+
const mockFetch = vi.fn().mockImplementation(() => {
708+
callCount++;
709+
if (callCount === 1) {
710+
return Promise.resolve(
711+
new Response(JSON.stringify({ id: 'msg_test' }), { status: 200 })
712+
);
713+
}
714+
return Promise.resolve(
715+
new Response(JSON.stringify({ error: { message: 'Model not found' } }), { status: 404 })
716+
);
717+
});
718+
globalThis.fetch = mockFetch as any;
719+
720+
const env = createMockBindings({ adminToken: 'test-token' });
721+
const request = createRequest('/admin/test-provider', {
722+
method: 'POST',
723+
token: 'test-token',
724+
body: {
725+
name: 'test-provider',
726+
baseUrl: 'https://api.example.com/v1/messages',
727+
apiKey: 'sk-test-key',
728+
},
729+
});
730+
731+
const response = await app.fetch(request, env);
732+
const data = await response.json() as any;
733+
734+
expect(data.success).toBe(false);
735+
expect(data.results.filter((r: any) => r.success)).toHaveLength(1);
736+
expect(data.results.filter((r: any) => !r.success)).toHaveLength(3);
737+
738+
globalThis.fetch = originalFetch;
739+
});
740+
741+
it('includes suggestion when models fail without mapping', async () => {
742+
const mockFetch = vi.fn().mockImplementation(() =>
743+
Promise.resolve(new Response(JSON.stringify({ error: { message: 'Not found' } }), { status: 404 }))
744+
);
745+
globalThis.fetch = mockFetch as any;
746+
747+
const env = createMockBindings({ adminToken: 'test-token' });
748+
const request = createRequest('/admin/test-provider', {
749+
method: 'POST',
750+
token: 'test-token',
751+
body: {
752+
name: 'test-provider',
753+
baseUrl: 'https://api.example.com/v1/messages',
754+
apiKey: 'sk-test-key',
755+
},
756+
});
757+
758+
const response = await app.fetch(request, env);
759+
const data = await response.json() as any;
760+
761+
expect(data.success).toBe(false);
762+
expect(data.suggestion).toBeDefined();
763+
expect(data.suggestion).toContain('model mapping');
764+
765+
globalThis.fetch = originalFetch;
766+
});
767+
768+
it('does not include suggestion when failed models have mappings', async () => {
769+
const mockFetch = vi.fn().mockImplementation(() =>
770+
Promise.resolve(new Response(JSON.stringify({ error: { message: 'Auth error' } }), { status: 401 }))
771+
);
772+
globalThis.fetch = mockFetch as any;
773+
774+
const env = createMockBindings({ adminToken: 'test-token' });
775+
const request = createRequest('/admin/test-provider', {
776+
method: 'POST',
777+
token: 'test-token',
778+
body: {
779+
name: 'test-provider',
780+
baseUrl: 'https://api.example.com/v1/messages',
781+
apiKey: 'sk-test-key',
782+
modelMapping: {
783+
'claude-sonnet-4-20250514': 'mapped-sonnet',
784+
'claude-opus-4-20250514': 'mapped-opus',
785+
'claude-opus-4-6-20250415': 'mapped-opus-46',
786+
'claude-3-5-haiku-20241022': 'mapped-haiku',
787+
},
788+
},
789+
});
790+
791+
const response = await app.fetch(request, env);
792+
const data = await response.json() as any;
793+
794+
expect(data.success).toBe(false);
795+
expect(data.suggestion).toBeUndefined();
796+
797+
globalThis.fetch = originalFetch;
798+
});
633799
});

src/__tests__/admin.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ describe('adminPage', () => {
312312
expect(html).toContain('priority-badge');
313313
});
314314

315-
it('includes move up/down buttons for provider reordering', async () => {
315+
it('includes toggle switch for provider enable/disable', async () => {
316316
const env = createMockBindings({
317317
adminToken: 'test-token',
318318
kvData: { providers: JSON.stringify(multipleProviders) },
@@ -322,8 +322,9 @@ describe('adminPage', () => {
322322
const response = await app.fetch(request, env);
323323
const html = await response.text();
324324

325-
expect(html).toContain('moveProviderUp');
326-
expect(html).toContain('moveProviderDown');
325+
expect(html).toContain('toggle-switch');
326+
expect(html).toContain('toggleProvider');
327+
expect(html).toContain('toggleAnthropicPrimary');
327328
expect(html).toContain('reorderProvider');
328329
});
329330

0 commit comments

Comments
 (0)