@starlink-awaken/agentmesh 1.0.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +60 -41
  2. package/README.zh-CN.md +137 -167
  3. package/config/gateway.yaml +78 -0
  4. package/dist/src/cli/connect.d.ts +26 -0
  5. package/dist/src/cli/connect.js +544 -0
  6. package/dist/src/cli/setup.d.ts +2 -0
  7. package/dist/src/cli/setup.js +97 -0
  8. package/dist/src/cli.js +410 -0
  9. package/dist/src/core/logger.d.ts +14 -0
  10. package/dist/src/core/logger.js +57 -0
  11. package/dist/{index.js → src/index.js} +40 -8
  12. package/dist/src/model-gateway/circuit-breaker.d.ts +21 -0
  13. package/dist/src/model-gateway/circuit-breaker.js +86 -0
  14. package/dist/src/model-gateway/health.d.ts +12 -0
  15. package/dist/src/model-gateway/health.js +80 -0
  16. package/dist/src/model-gateway/providers.d.ts +4 -0
  17. package/dist/src/model-gateway/providers.js +113 -0
  18. package/dist/src/model-gateway/quota.d.ts +4 -0
  19. package/dist/src/model-gateway/quota.js +107 -0
  20. package/dist/src/model-gateway/rate-limit.d.ts +12 -0
  21. package/dist/src/model-gateway/rate-limit.js +51 -0
  22. package/dist/src/model-gateway/retry.d.ts +14 -0
  23. package/dist/src/model-gateway/retry.js +48 -0
  24. package/dist/src/model-gateway/router.d.ts +4 -0
  25. package/dist/src/model-gateway/router.js +79 -0
  26. package/dist/src/model-gateway/routes.d.ts +2 -0
  27. package/dist/src/model-gateway/routes.js +172 -0
  28. package/dist/src/model-gateway/types.d.ts +47 -0
  29. package/dist/src/model-gateway/types.js +1 -0
  30. package/dist/tests/core/context-manager.test.d.ts +1 -0
  31. package/dist/tests/core/context-manager.test.js +35 -0
  32. package/dist/tests/core/router.test.d.ts +1 -0
  33. package/dist/tests/core/router.test.js +79 -0
  34. package/dist/tests/model-gateway/circuit-breaker.test.d.ts +1 -0
  35. package/dist/tests/model-gateway/circuit-breaker.test.js +84 -0
  36. package/dist/tests/model-gateway/providers.test.d.ts +1 -0
  37. package/dist/tests/model-gateway/providers.test.js +80 -0
  38. package/dist/tests/model-gateway/quota.test.d.ts +1 -0
  39. package/dist/tests/model-gateway/quota.test.js +60 -0
  40. package/dist/tests/model-gateway/rate-limit.test.d.ts +1 -0
  41. package/dist/tests/model-gateway/rate-limit.test.js +42 -0
  42. package/dist/tests/model-gateway/retry.test.d.ts +1 -0
  43. package/dist/tests/model-gateway/retry.test.js +47 -0
  44. package/dist/tests/model-gateway/router.test.d.ts +1 -0
  45. package/dist/tests/model-gateway/router.test.js +108 -0
  46. package/dist/tests/model-gateway/routes.test.d.ts +1 -0
  47. package/dist/tests/model-gateway/routes.test.js +83 -0
  48. package/docs/api.md +187 -460
  49. package/docs/architecture.md +138 -0
  50. package/docs/configuration.md +188 -0
  51. package/package.json +3 -1
  52. package/dist/cli.js +0 -246
  53. /package/dist/{adapters → src/adapters}/base.d.ts +0 -0
  54. /package/dist/{adapters → src/adapters}/base.js +0 -0
  55. /package/dist/{adapters → src/adapters}/claude-code.d.ts +0 -0
  56. /package/dist/{adapters → src/adapters}/claude-code.js +0 -0
  57. /package/dist/{adapters → src/adapters}/openclaw.d.ts +0 -0
  58. /package/dist/{adapters → src/adapters}/openclaw.js +0 -0
  59. /package/dist/{adapters → src/adapters}/process.d.ts +0 -0
  60. /package/dist/{adapters → src/adapters}/process.js +0 -0
  61. /package/dist/{cli.d.ts → src/cli.d.ts} +0 -0
  62. /package/dist/{core → src/core}/agent-registry.d.ts +0 -0
  63. /package/dist/{core → src/core}/agent-registry.js +0 -0
  64. /package/dist/{core → src/core}/config.d.ts +0 -0
  65. /package/dist/{core → src/core}/config.js +0 -0
  66. /package/dist/{core → src/core}/context-manager.d.ts +0 -0
  67. /package/dist/{core → src/core}/context-manager.js +0 -0
  68. /package/dist/{core → src/core}/event-bus.d.ts +0 -0
  69. /package/dist/{core → src/core}/event-bus.js +0 -0
  70. /package/dist/{core → src/core}/metrics.d.ts +0 -0
  71. /package/dist/{core → src/core}/metrics.js +0 -0
  72. /package/dist/{core → src/core}/router.d.ts +0 -0
  73. /package/dist/{core → src/core}/router.js +0 -0
  74. /package/dist/{core → src/core}/task-manager.d.ts +0 -0
  75. /package/dist/{core → src/core}/task-manager.js +0 -0
  76. /package/dist/{core → src/core}/vector-store.d.ts +0 -0
  77. /package/dist/{core → src/core}/vector-store.js +0 -0
  78. /package/dist/{index.d.ts → src/index.d.ts} +0 -0
  79. /package/dist/{routes → src/routes}/api.d.ts +0 -0
  80. /package/dist/{routes → src/routes}/api.js +0 -0
  81. /package/dist/{routes → src/routes}/websocket.d.ts +0 -0
  82. /package/dist/{routes → src/routes}/websocket.js +0 -0
  83. /package/dist/{types → src/types}/index.d.ts +0 -0
  84. /package/dist/{types → src/types}/index.js +0 -0
@@ -0,0 +1,172 @@
1
+ import { resolveProvider, getConfig } from './router.js';
2
+ import { callChatCompletions, callResponsesApi } from './providers.js';
3
+ import { getQuotaSummary, probeQuota } from './quota.js';
4
+ import { circuitBreakerRegistry } from './circuit-breaker.js';
5
+ import { checkAllProviders } from './health.js';
6
+ export async function modelGatewayRoutes(fastify) {
7
+ // 健康检查 + 配额总览
8
+ fastify.get('/model-gateway/health', async (_req, _reply) => {
9
+ const quota = await probeQuota();
10
+ return {
11
+ status: 'ok',
12
+ timestamp: Date.now(),
13
+ providers_available: Array.from(quota.entries())
14
+ .filter(([_, info]) => info.available)
15
+ .map(([name, info]) => ({ name, summary: info.summary })),
16
+ providers_unavailable: Array.from(quota.entries())
17
+ .filter(([_, info]) => !info.available)
18
+ .map(([name, info]) => ({ name, summary: info.summary })),
19
+ };
20
+ });
21
+ // 配额详情
22
+ fastify.get('/model-gateway/quota', async (_req, reply) => {
23
+ await probeQuota();
24
+ reply.send(getQuotaSummary());
25
+ });
26
+ // 可用模型列表
27
+ fastify.get('/v1/models', async (_req, reply) => {
28
+ const { getConfig } = await import('./router.js');
29
+ const cfg = getConfig();
30
+ const models = [];
31
+ if (cfg) {
32
+ for (const [providerName, providerCfg] of Object.entries(cfg.providers)) {
33
+ const providerModels = providerCfg.models || [providerName];
34
+ for (const model of providerModels) {
35
+ models.push({ id: model, object: 'model', owned_by: providerName });
36
+ }
37
+ }
38
+ }
39
+ reply.send({ object: 'list', data: models });
40
+ });
41
+ // POST /v1/chat/completions — 标准 OpenAI 兼容端点
42
+ fastify.post('/v1/chat/completions', async (request, reply) => {
43
+ const body = request.body;
44
+ if (!body || !body.messages) {
45
+ return reply.code(400).send({ error: { message: 'messages is required' } });
46
+ }
47
+ const model = body.model || 'deepseek-chat';
48
+ const provider = resolveProvider(model);
49
+ if (!provider) {
50
+ return reply.code(503).send({
51
+ error: { message: 'No available provider. Check API keys and quota.' },
52
+ });
53
+ }
54
+ console.log(`[ModelGW] ${model} → ${provider.name} (${body.stream ? 'stream' : 'sync'})`);
55
+ try {
56
+ const upstreamResp = await callChatCompletions(provider, {
57
+ model,
58
+ messages: body.messages,
59
+ stream: body.stream,
60
+ temperature: body.temperature,
61
+ max_tokens: body.max_tokens,
62
+ tools: body.tools,
63
+ tool_choice: body.tool_choice,
64
+ });
65
+ if (!upstreamResp.ok && upstreamResp.status !== 200) {
66
+ const errText = await upstreamResp.text();
67
+ console.error(`[ModelGW] ${provider.name} error ${upstreamResp.status}: ${errText.slice(0, 200)}`);
68
+ return reply.code(upstreamResp.status).send({
69
+ error: { message: `${provider.name}: ${errText.slice(0, 500)}` },
70
+ });
71
+ }
72
+ if (body.stream) {
73
+ return reply.headers({
74
+ 'Content-Type': 'text/event-stream',
75
+ 'Cache-Control': 'no-cache',
76
+ Connection: 'keep-alive',
77
+ }).send(upstreamResp.body);
78
+ }
79
+ const data = await upstreamResp.json();
80
+ reply.send(data);
81
+ }
82
+ catch (err) {
83
+ console.error(`[ModelGW] Error calling ${provider.name}:`, err.message);
84
+ reply.code(502).send({
85
+ error: { message: `Provider error: ${err.message}` },
86
+ });
87
+ }
88
+ });
89
+ // POST /v1/responses — Codex Desktop Responses API 适配
90
+ fastify.post('/v1/responses', async (request, reply) => {
91
+ const body = request.body;
92
+ if (!body || !body.input) {
93
+ return reply.code(400).send({ error: { message: 'input is required' } });
94
+ }
95
+ const model = body.model || 'deepseek-chat';
96
+ const provider = resolveProvider(model);
97
+ if (!provider) {
98
+ return reply.code(503).send({
99
+ error: { message: 'No available provider. Check API keys and quota.' },
100
+ });
101
+ }
102
+ console.log(`[ModelGW:Responses] ${model} → ${provider.name}`);
103
+ try {
104
+ const upstreamResp = await callResponsesApi(provider, body);
105
+ if (!upstreamResp.ok) {
106
+ const errText = await upstreamResp.text();
107
+ return reply.code(upstreamResp.status).send({
108
+ error: { message: `${provider.name}: ${errText.slice(0, 500)}` },
109
+ });
110
+ }
111
+ if (body.stream) {
112
+ return reply.headers({
113
+ 'Content-Type': 'text/event-stream',
114
+ 'Cache-Control': 'no-cache',
115
+ }).send(upstreamResp.body);
116
+ }
117
+ // 将 Chat Completions 响应转回 Responses API 格式
118
+ const ccData = (await upstreamResp.json());
119
+ const choice = ccData.choices?.[0];
120
+ const responsesData = {
121
+ id: ccData.id,
122
+ object: 'response',
123
+ model: ccData.model,
124
+ output: [
125
+ {
126
+ type: 'message',
127
+ role: 'assistant',
128
+ content: [
129
+ {
130
+ type: 'output_text',
131
+ text: choice?.message?.content || '',
132
+ },
133
+ ],
134
+ },
135
+ ],
136
+ usage: ccData.usage,
137
+ };
138
+ reply.send(responsesData);
139
+ }
140
+ catch (err) {
141
+ console.error(`[ModelGW:Responses] Error:`, err.message);
142
+ reply.code(502).send({
143
+ error: { message: `Provider error: ${err.message}` },
144
+ });
145
+ }
146
+ });
147
+ // Provider 健康检查 + 熔断器状态
148
+ fastify.get('/model-gateway/health/:provider', async (request, reply) => {
149
+ const { provider } = request.params;
150
+ const cfg = getConfig();
151
+ if (!cfg || !cfg.providers[provider]) {
152
+ return reply.code(404).send({ error: { message: `Provider '${provider}' not found` } });
153
+ }
154
+ const results = await checkAllProviders(cfg);
155
+ const result = results.find(r => r.provider === provider);
156
+ if (!result) {
157
+ return reply.code(404).send({ error: { message: `Health check not available for '${provider}'` } });
158
+ }
159
+ reply.send({ ...result, circuit_breaker: circuitBreakerRegistry.getStatus() });
160
+ });
161
+ // 全部 Provider 健康检查
162
+ fastify.get('/model-gateway/health/all', async (_request, reply) => {
163
+ const cfg = getConfig();
164
+ if (!cfg)
165
+ return reply.code(503).send({ error: { message: 'Model gateway not configured' } });
166
+ const results = await checkAllProviders(cfg);
167
+ reply.send({
168
+ providers: results,
169
+ circuit_breaker: circuitBreakerRegistry.getStatus(),
170
+ });
171
+ });
172
+ }
@@ -0,0 +1,47 @@
1
+ export interface ModelProviderConfig {
2
+ base_url: string;
3
+ api_key_env?: string;
4
+ api_key?: string;
5
+ models?: string[];
6
+ }
7
+ export interface ModelGatewayConfig {
8
+ default_model: string;
9
+ providers: Record<string, ModelProviderConfig>;
10
+ fallback_chain: string[];
11
+ model_routing: Record<string, string[]>;
12
+ }
13
+ export interface ResolvedProvider {
14
+ name: string;
15
+ base_url: string;
16
+ api_key: string;
17
+ }
18
+ export interface QuotaInfo {
19
+ provider: string;
20
+ available: boolean;
21
+ usedPercent?: number;
22
+ balance?: number;
23
+ summary: string;
24
+ }
25
+ export interface ChatCompletionRequest {
26
+ model: string;
27
+ messages: Array<{
28
+ role: string;
29
+ content: string | any;
30
+ }>;
31
+ stream?: boolean;
32
+ temperature?: number;
33
+ max_tokens?: number;
34
+ tools?: any[];
35
+ tool_choice?: any;
36
+ }
37
+ export interface ResponsesRequest {
38
+ model: string;
39
+ input: Array<{
40
+ role?: string;
41
+ type?: string;
42
+ content?: string | any[];
43
+ }>;
44
+ stream?: boolean;
45
+ tools?: any[];
46
+ instructions?: string;
47
+ }
@@ -0,0 +1 @@
1
+ // 模型网关类型定义
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { describe, test, expect, beforeEach } from 'bun:test';
2
+ import { ContextManager } from '../../src/core/context-manager.js';
3
+ describe('context manager', () => {
4
+ let cm;
5
+ beforeEach(() => {
6
+ cm = new ContextManager();
7
+ });
8
+ test('creates shared space with id', async () => {
9
+ const spaceId = await cm.createSharedSpace({ name: 'test' });
10
+ expect(spaceId).toBeDefined();
11
+ expect(typeof spaceId).toBe('string');
12
+ expect(spaceId.length).toBeGreaterThan(0);
13
+ });
14
+ test('retrieves created shared space', async () => {
15
+ const spaceId = await cm.createSharedSpace({ name: 'test' });
16
+ const space = await cm.getSharedSpace(spaceId);
17
+ expect(space).toBeDefined();
18
+ expect(space.shared_space_id).toBe(spaceId);
19
+ });
20
+ test('returns null for non-existent space', async () => {
21
+ const space = await cm.getSharedSpace('non-existent-id');
22
+ expect(space).toBeNull();
23
+ });
24
+ test('different calls create different space ids', async () => {
25
+ const id1 = await cm.createSharedSpace({});
26
+ const id2 = await cm.createSharedSpace({});
27
+ expect(id1).not.toBe(id2);
28
+ });
29
+ test('space stores metadata', async () => {
30
+ const metadata = { project: 'test', version: 1 };
31
+ const spaceId = await cm.createSharedSpace(metadata);
32
+ const space = await cm.getSharedSpace(spaceId);
33
+ expect(space.metadata).toEqual(metadata);
34
+ });
35
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,79 @@
1
+ import { describe, test, expect, beforeEach } from 'bun:test';
2
+ import { Router } from '../../src/core/router.js';
3
+ function makeAgent(id, caps, status = 'online') {
4
+ return { id, name: `Agent ${id}`, type: 'process', capabilities: caps, status, lastSeen: Date.now() };
5
+ }
6
+ describe('agent router', () => {
7
+ let router;
8
+ beforeEach(() => {
9
+ router = new Router();
10
+ });
11
+ test('registers and retrieves agents', () => {
12
+ router.registerAgent(makeAgent('test-agent', ['code-generation']));
13
+ expect(router.getAgent('test-agent').id).toBe('test-agent');
14
+ });
15
+ test('unregisters agents', () => {
16
+ router.registerAgent(makeAgent('test-agent', ['code-generation']));
17
+ router.unregisterAgent('test-agent');
18
+ expect(router.getAgent('test-agent')).toBeUndefined();
19
+ });
20
+ test('filters offline from online agents', () => {
21
+ router.registerAgent(makeAgent('a', ['x'], 'online'));
22
+ router.registerAgent(makeAgent('b', ['x'], 'offline'));
23
+ router.registerAgent(makeAgent('c', ['x'], 'online'));
24
+ expect(router.getOnlineAgents()).toHaveLength(2);
25
+ });
26
+ test('routes by keyword match', () => {
27
+ const rules = [
28
+ { name: 'review', keywords: ['review', 'code review'], agent: 'review-bot', priority: 15 },
29
+ { name: 'code', keywords: ['write code'], agent: 'code-bot', priority: 10 },
30
+ ];
31
+ router.configure(rules, 'fallback');
32
+ router.registerAgent(makeAgent('review-bot', ['review']));
33
+ router.registerAgent(makeAgent('code-bot', ['coding']));
34
+ router.registerAgent(makeAgent('fallback', ['general']));
35
+ const msg = {
36
+ id: '1', type: 'request', source: 'test', target: 'gateway',
37
+ correlation_id: '1', timestamp: Date.now(),
38
+ payload: { task: 'please review this code' },
39
+ };
40
+ const result = router.route(msg);
41
+ expect(result.agentIds).toContain('review-bot');
42
+ });
43
+ test('higher priority wins when keywords overlap', () => {
44
+ const rules = [
45
+ { name: 'low', keywords: ['code'], agent: 'low', priority: 5 },
46
+ { name: 'high', keywords: ['code'], agent: 'high', priority: 20 },
47
+ ];
48
+ router.configure(rules);
49
+ router.registerAgent(makeAgent('low', ['general']));
50
+ router.registerAgent(makeAgent('high', ['general']));
51
+ const msg = {
52
+ id: '1', type: 'request', source: 'test', target: 'gateway',
53
+ correlation_id: '1', timestamp: Date.now(),
54
+ payload: { task: 'help with code' },
55
+ };
56
+ expect(router.route(msg).agentIds).toContain('high');
57
+ });
58
+ test('falls back to default when no rule matches', () => {
59
+ router.configure([], 'default-bot');
60
+ router.registerAgent(makeAgent('default-bot', ['general']));
61
+ const msg = {
62
+ id: '1', type: 'request', source: 'test', target: 'gateway',
63
+ correlation_id: '1', timestamp: Date.now(),
64
+ payload: { task: 'something unrelated' },
65
+ };
66
+ expect(router.route(msg).agentIds).toContain('default-bot');
67
+ });
68
+ test('skips offline agents and uses fallback', () => {
69
+ router.configure([{ name: 'code', keywords: ['code'], agent: 'offline-bot', priority: 10 }], 'online-fallback');
70
+ router.registerAgent(makeAgent('offline-bot', ['coding'], 'offline'));
71
+ router.registerAgent(makeAgent('online-fallback', ['general'], 'online'));
72
+ const msg = {
73
+ id: '1', type: 'request', source: 'test', target: 'gateway',
74
+ correlation_id: '1', timestamp: Date.now(),
75
+ payload: { task: 'write code' },
76
+ };
77
+ expect(router.route(msg).agentIds).toContain('online-fallback');
78
+ });
79
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,84 @@
1
+ import { describe, test, expect, beforeEach } from 'bun:test';
2
+ import { CircuitBreakerRegistry } from '../../src/model-gateway/circuit-breaker.js';
3
+ describe('circuit breaker', () => {
4
+ let cb;
5
+ beforeEach(() => {
6
+ cb = new CircuitBreakerRegistry();
7
+ });
8
+ test('starts in CLOSED state', () => {
9
+ expect(cb.getState('test')).toBe('CLOSED');
10
+ expect(cb.isOpen('test')).toBe(false);
11
+ expect(cb.canRequest('test')).toBe(true);
12
+ });
13
+ test('opens after failure threshold', () => {
14
+ cb.configure('test', { failureThreshold: 3, resetTimeoutMs: 30000 });
15
+ cb.recordFailure('test');
16
+ cb.recordFailure('test');
17
+ expect(cb.isOpen('test')).toBe(false); // 2 failures, not yet
18
+ cb.recordFailure('test');
19
+ expect(cb.isOpen('test')).toBe(true); // 3rd failure triggers open
20
+ expect(cb.canRequest('test')).toBe(false);
21
+ });
22
+ test('stays closed with intermittent successes', () => {
23
+ cb.configure('test', { failureThreshold: 3, resetTimeoutMs: 30000 });
24
+ cb.recordFailure('test');
25
+ cb.recordSuccess('test'); // reset counter
26
+ cb.recordFailure('test');
27
+ cb.recordFailure('test');
28
+ expect(cb.isOpen('test')).toBe(false); // only 2 consecutive failures
29
+ });
30
+ test('transitions to HALF_OPEN after reset timeout', () => {
31
+ cb.configure('test', { failureThreshold: 1, resetTimeoutMs: 1 });
32
+ cb.recordFailure('test');
33
+ expect(cb.isOpen('test')).toBe(true);
34
+ // 等待足够时间后应转为 HALF_OPEN
35
+ // 但 1ms 太快,实际上还没到,需要手动模拟
36
+ // 直接测试 getState 在 OPEN 状态时的行为
37
+ expect(cb.getState('test')).toBe('OPEN');
38
+ });
39
+ test('half-open allows limited probe requests', () => {
40
+ // 直接操作内部状态不方便,测试 canRequest 在 HALF_OPEN 时行为
41
+ cb.configure('test', { failureThreshold: 3, resetTimeoutMs: 30000, halfOpenMaxRequests: 1 });
42
+ // 使熔断器打开
43
+ cb.recordFailure('test');
44
+ cb.recordFailure('test');
45
+ cb.recordFailure('test');
46
+ expect(cb.isOpen('test')).toBe(true);
47
+ // canRequest 在 OPEN 时应返回 false
48
+ expect(cb.canRequest('test')).toBe(false);
49
+ });
50
+ test('recordSuccess resets circuit on half-open', () => {
51
+ cb.configure('test', { failureThreshold: 3, resetTimeoutMs: 30000 });
52
+ cb.recordFailure('test');
53
+ cb.recordSuccess('test');
54
+ expect(cb.getState('test')).toBe('CLOSED');
55
+ expect(cb.isOpen('test')).toBe(false);
56
+ });
57
+ test('recordFailure on half-open reopens circuit', () => {
58
+ cb.configure('test', { failureThreshold: 1, resetTimeoutMs: 3600_000 }); // 1h reset
59
+ cb.recordFailure('test');
60
+ expect(cb.isOpen('test')).toBe(true);
61
+ // 无法手动设置 HALF_OPEN,但重开后 recordFailure 应保持 OPEN
62
+ cb.recordFailure('test');
63
+ expect(cb.isOpen('test')).toBe(true);
64
+ });
65
+ test('independent circuits per provider', () => {
66
+ cb.configure('deepseek', { failureThreshold: 3, resetTimeoutMs: 30000 });
67
+ cb.configure('openai', { failureThreshold: 3, resetTimeoutMs: 30000 });
68
+ cb.recordFailure('deepseek');
69
+ cb.recordFailure('deepseek');
70
+ cb.recordFailure('deepseek');
71
+ expect(cb.isOpen('deepseek')).toBe(true);
72
+ expect(cb.isOpen('openai')).toBe(false);
73
+ });
74
+ test('getStatus returns all circuit states', () => {
75
+ cb.recordFailure('p1');
76
+ cb.recordFailure('p1');
77
+ cb.recordFailure('p1');
78
+ cb.recordFailure('p2');
79
+ const status = cb.getStatus();
80
+ expect(status['p1'].state).toBe('OPEN');
81
+ expect(status['p1'].failures).toBe(3);
82
+ expect(status['p2'].failures).toBe(1);
83
+ });
84
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,80 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ // 测试 Responses API → Chat Completions 转换的辅助函数
3
+ // 直接测试 extractTextContent 和 convertResponsesInputToMessages 逻辑
4
+ describe('Responses API adapter — content extraction', () => {
5
+ test('extracts string content', () => {
6
+ const content = 'Hello, world!';
7
+ const result = typeof content === 'string' ? content : String(content);
8
+ expect(result).toBe('Hello, world!');
9
+ });
10
+ test('extracts input_text from content array', () => {
11
+ const content = [
12
+ { type: 'input_text', text: 'Write a function' },
13
+ { type: 'input_text', text: 'Use TypeScript' },
14
+ ];
15
+ const result = content
16
+ .filter((p) => p.type === 'input_text')
17
+ .map((p) => p.text)
18
+ .join('\n');
19
+ expect(result).toBe('Write a function\nUse TypeScript');
20
+ });
21
+ test('handles mixed content types', () => {
22
+ const content = [
23
+ { type: 'input_text', text: 'Hello' },
24
+ { type: 'image', url: 'https://example.com/img.png' },
25
+ { type: 'input_text', text: 'World' },
26
+ ];
27
+ const result = content
28
+ .filter((p) => p.type === 'input_text')
29
+ .map((p) => p.text)
30
+ .join('\n');
31
+ expect(result).toBe('Hello\nWorld');
32
+ });
33
+ test('handles empty content array', () => {
34
+ const content = [];
35
+ const result = content
36
+ .filter((p) => p.type === 'input_text')
37
+ .map((p) => p.text)
38
+ .join('\n');
39
+ expect(result).toBe('');
40
+ });
41
+ });
42
+ describe('Responses API adapter — input conversion', () => {
43
+ test('converts role-based items to messages', () => {
44
+ const input = [
45
+ { role: 'system', content: 'You are a helpful assistant.' },
46
+ { role: 'user', content: 'Hello!' },
47
+ ];
48
+ const messages = input.map((item) => ({
49
+ role: item.role,
50
+ content: typeof item.content === 'string' ? item.content : '',
51
+ }));
52
+ expect(messages).toEqual([
53
+ { role: 'system', content: 'You are a helpful assistant.' },
54
+ { role: 'user', content: 'Hello!' },
55
+ ]);
56
+ });
57
+ test('converts type="message" items to messages', () => {
58
+ const input = [
59
+ { type: 'message', role: 'user', content: 'Hi there' },
60
+ ];
61
+ const messages = input.map((item) => ({
62
+ role: item.role || 'user',
63
+ content: typeof item.content === 'string' ? item.content : '',
64
+ }));
65
+ expect(messages).toEqual([
66
+ { role: 'user', content: 'Hi there' },
67
+ ]);
68
+ });
69
+ test('prepends instructions as system message', () => {
70
+ const instructions = 'Be concise and helpful.';
71
+ const existingMessages = [{ role: 'user', content: 'Hello' }];
72
+ const messages = [
73
+ { role: 'system', content: instructions },
74
+ ...existingMessages,
75
+ ];
76
+ expect(messages).toHaveLength(2);
77
+ expect(messages[0].role).toBe('system');
78
+ expect(messages[0].content).toBe(instructions);
79
+ });
80
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,60 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ // 测试 codexbar JSON 解析逻辑 — 直接测 parseQuota 的等效逻辑
3
+ describe('quota parsing', () => {
4
+ test('deepseek balance parsing from ¥ format', () => {
5
+ const desc = 'Balance: ¥561.18';
6
+ const match = desc.match(/¥([\d.]+)/);
7
+ const balance = match ? parseFloat(match[1]) : -1;
8
+ expect(balance).toBe(561.18);
9
+ expect(balance > 0).toBe(true);
10
+ });
11
+ test('deepseek zero balance', () => {
12
+ const desc = 'Balance: ¥0.00';
13
+ const match = desc.match(/¥([\d.]+)/);
14
+ const balance = match ? parseFloat(match[1]) : -1;
15
+ expect(balance).toBe(0);
16
+ expect(balance > 0).toBe(false);
17
+ });
18
+ test('deepseek no balance in description', () => {
19
+ const desc = 'No balance info';
20
+ const match = desc.match(/¥([\d.]+)/);
21
+ const balance = match ? parseFloat(match[1]) : -1;
22
+ expect(balance).toBe(-1);
23
+ expect(balance > 0).toBe(false);
24
+ });
25
+ test('openrouter balance parsing', () => {
26
+ const orUsage = { balance: 26.72, usedPercent: 46.56 };
27
+ expect(orUsage.balance).toBeGreaterThan(0);
28
+ expect(orUsage.usedPercent).toBe(46.56);
29
+ });
30
+ test('codex credits check: exhausted', () => {
31
+ const credits = { remaining: 0 };
32
+ expect(credits.remaining > 0).toBe(false);
33
+ });
34
+ test('codex credits check: available', () => {
35
+ const credits = { remaining: 100 };
36
+ expect(credits.remaining > 0).toBe(true);
37
+ });
38
+ test('gemini usedPercent thresholds', () => {
39
+ expect(5.3 < 95).toBe(true); // available
40
+ expect(98 < 95).toBe(false); // exhausted
41
+ expect(0 < 95).toBe(true); // available
42
+ });
43
+ test('ollama always available', () => {
44
+ // Ollama has no quota concept — always available
45
+ const available = true;
46
+ expect(available).toBe(true);
47
+ });
48
+ test('cache TTL logic — fresh cache returned', () => {
49
+ const now = Date.now();
50
+ const lastProbe = now - 30_000; // 30s ago, within 60s TTL
51
+ const TTL = 60_000;
52
+ expect(now - lastProbe < TTL).toBe(true);
53
+ });
54
+ test('cache TTL logic — stale cache refreshed', () => {
55
+ const now = Date.now();
56
+ const lastProbe = now - 65_000; // 65s ago, exceeds 60s TTL
57
+ const TTL = 60_000;
58
+ expect(now - lastProbe < TTL).toBe(false);
59
+ });
60
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import { describe, test, expect, beforeEach } from 'bun:test';
2
+ import { initRateLimiter, checkRateLimit } from '../../src/model-gateway/rate-limit.js';
3
+ describe('rate limiter', () => {
4
+ beforeEach(() => {
5
+ initRateLimiter();
6
+ });
7
+ test('allows first request within limit', () => {
8
+ const result = checkRateLimit('/v1/chat/completions', '127.0.0.1');
9
+ expect(result.allowed).toBe(true);
10
+ expect(result.limit).toBe(60);
11
+ expect(result.remaining).toBe(59);
12
+ });
13
+ test('decrements tokens on subsequent requests', () => {
14
+ let result = checkRateLimit('/v1/chat/completions', '10.0.0.1');
15
+ expect(result.allowed).toBe(true);
16
+ expect(result.remaining).toBe(59);
17
+ result = checkRateLimit('/v1/chat/completions', '10.0.0.1');
18
+ expect(result.allowed).toBe(true);
19
+ expect(result.remaining).toBe(58);
20
+ });
21
+ test('independent limits per IP', () => {
22
+ const a = checkRateLimit('/v1/chat/completions', '1.1.1.1');
23
+ const b = checkRateLimit('/v1/chat/completions', '2.2.2.2');
24
+ expect(a.remaining).toBe(59);
25
+ expect(b.remaining).toBe(59);
26
+ });
27
+ test('independent limits per endpoint', () => {
28
+ const chat = checkRateLimit('/v1/chat/completions', '3.3.3.3');
29
+ const resp = checkRateLimit('/v1/responses', '3.3.3.3');
30
+ expect(chat.limit).toBe(60);
31
+ expect(resp.limit).toBe(30);
32
+ });
33
+ test('unconfigured path is not rate limited', () => {
34
+ const result = checkRateLimit('/v1/models', '4.4.4.4');
35
+ expect(result.allowed).toBe(true);
36
+ expect(result.limit).toBe(0);
37
+ });
38
+ test('returns reset time when tokens are low', () => {
39
+ const result = checkRateLimit('/v1/chat/completions', '5.5.5.5');
40
+ expect(result.resetSeconds).toBeGreaterThanOrEqual(0);
41
+ });
42
+ });
@@ -0,0 +1 @@
1
+ export {};