@aibridge/cli 0.0.1
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.
- package/LICENSE +21 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +6 -0
- package/dist/context-BLjTHa41.mjs +1529 -0
- package/dist/index.d.mts +184 -0
- package/dist/index.mjs +2 -0
- package/package.json +53 -0
- package/src/app.exit-code.test.ts +91 -0
- package/src/app.ts +49 -0
- package/src/cli.ts +5 -0
- package/src/commands/image-gen/command.ts +77 -0
- package/src/commands/image-gen/impl.ts +268 -0
- package/src/commands/implement/command.ts +50 -0
- package/src/commands/implement/impl.ts +99 -0
- package/src/commands/plan/command.ts +56 -0
- package/src/commands/plan/impl.ts +172 -0
- package/src/commands/plan/plan.test.ts +19 -0
- package/src/commands/quota/command.ts +30 -0
- package/src/commands/quota/impl.ts +109 -0
- package/src/commands/review/command.ts +58 -0
- package/src/commands/review/impl.ts +211 -0
- package/src/commands/review/review.test.ts +54 -0
- package/src/commands/runs/command.ts +53 -0
- package/src/commands/runs/impl.ts +171 -0
- package/src/commands/subagent/command.ts +62 -0
- package/src/commands/subagent/impl.ts +87 -0
- package/src/context.ts +10 -0
- package/src/delegate.test.ts +180 -0
- package/src/delegate.ts +46 -0
- package/src/driver.ts +56 -0
- package/src/drivers.ts +44 -0
- package/src/exitCode.test.ts +44 -0
- package/src/exitCode.ts +24 -0
- package/src/flagMapping.test.ts +99 -0
- package/src/index.ts +37 -0
- package/src/models.test.ts +107 -0
- package/src/models.ts +159 -0
- package/src/parsers.ts +24 -0
- package/src/quotaPreflight.test.ts +178 -0
- package/src/quotaPreflight.ts +103 -0
- package/src/runlog.ts +195 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
backendModelId,
|
|
4
|
+
formatImageGenModelError,
|
|
5
|
+
formatUnknownModelError,
|
|
6
|
+
listModelHelpLines,
|
|
7
|
+
resolveModel,
|
|
8
|
+
supportsImageGen,
|
|
9
|
+
} from './models.ts';
|
|
10
|
+
|
|
11
|
+
describe('models registry', () => {
|
|
12
|
+
it('resolves canonical slugs', () => {
|
|
13
|
+
const grok = resolveModel('xai-grok/grok-4.5');
|
|
14
|
+
expect(grok).toBeDefined();
|
|
15
|
+
expect(grok?.spec.slug).toBe('xai-grok/grok-4.5');
|
|
16
|
+
expect(grok?.effort).toBeUndefined();
|
|
17
|
+
|
|
18
|
+
const gemini = resolveModel('google-antigravity/gemini-3.6-flash');
|
|
19
|
+
expect(gemini).toBeDefined();
|
|
20
|
+
expect(gemini?.spec.slug).toBe('google-antigravity/gemini-3.6-flash');
|
|
21
|
+
expect(gemini?.effort).toBe('high');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rejects short aliases — canonical slugs only', () => {
|
|
25
|
+
for (const alias of ['grok', 'gemini', 'codex', 'sonnet', 'opus', 'gemini-3.6', 'gpt-oss']) {
|
|
26
|
+
expect(resolveModel(alias)).toBeUndefined();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('resolves effort suffixes', () => {
|
|
31
|
+
const grokMedium = resolveModel('xai-grok/grok-4.5-medium');
|
|
32
|
+
expect(grokMedium?.spec.slug).toBe('xai-grok/grok-4.5');
|
|
33
|
+
expect(grokMedium?.effort).toBe('medium');
|
|
34
|
+
|
|
35
|
+
const sonnetMax = resolveModel('anthropic-claude/sonnet-max');
|
|
36
|
+
expect(sonnetMax?.spec.slug).toBe('anthropic-claude/sonnet');
|
|
37
|
+
expect(sonnetMax?.effort).toBe('max');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects unsupported efforts', () => {
|
|
41
|
+
const grokXhigh = resolveModel('xai-grok/grok-4.5-xhigh');
|
|
42
|
+
expect(grokXhigh).toBeUndefined();
|
|
43
|
+
|
|
44
|
+
const gptOssHigh = resolveModel('google-antigravity/gpt-oss-120b-medium-high');
|
|
45
|
+
expect(gptOssHigh).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('handles unknown models', () => {
|
|
49
|
+
expect(resolveModel('nonexistent-model')).toBeUndefined();
|
|
50
|
+
const err = formatUnknownModelError('nonexistent-model');
|
|
51
|
+
expect(err).toContain('Unknown model "nonexistent-model".');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('prioritizes exact matches over effort splitting', () => {
|
|
55
|
+
const gptOss = resolveModel('google-antigravity/gpt-oss-120b-medium');
|
|
56
|
+
expect(gptOss).toBeDefined();
|
|
57
|
+
expect(gptOss?.spec.slug).toBe('google-antigravity/gpt-oss-120b-medium');
|
|
58
|
+
expect(gptOss?.effort).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('computes backendModelId correctly for agy vs others', () => {
|
|
62
|
+
const gemini = resolveModel('google-antigravity/gemini-3.6-flash');
|
|
63
|
+
if (!gemini) throw new Error('gemini resolution failed');
|
|
64
|
+
expect(backendModelId(gemini)).toBe('gemini-3.6-flash-high');
|
|
65
|
+
|
|
66
|
+
const geminiLow = resolveModel('google-antigravity/gemini-3.6-flash-low');
|
|
67
|
+
if (!geminiLow) throw new Error('geminiLow resolution failed');
|
|
68
|
+
expect(backendModelId(geminiLow)).toBe('gemini-3.6-flash-low');
|
|
69
|
+
|
|
70
|
+
const grok = resolveModel('xai-grok/grok-4.5');
|
|
71
|
+
if (!grok) throw new Error('grok resolution failed');
|
|
72
|
+
expect(backendModelId(grok)).toBe('grok-4.5');
|
|
73
|
+
|
|
74
|
+
const sonnet = resolveModel('anthropic-claude/sonnet');
|
|
75
|
+
if (!sonnet) throw new Error('sonnet resolution failed');
|
|
76
|
+
expect(backendModelId(sonnet)).toBe('sonnet');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('marks only codex and grok seats as image-gen capable', () => {
|
|
80
|
+
const codex = resolveModel('openai-codex/gpt-5.6-sol');
|
|
81
|
+
const grok = resolveModel('xai-grok/grok-4.5');
|
|
82
|
+
const gemini = resolveModel('google-antigravity/gemini-3.6-flash');
|
|
83
|
+
if (!codex || !grok || !gemini) throw new Error('resolution failed');
|
|
84
|
+
expect(supportsImageGen(codex)).toBe(true);
|
|
85
|
+
expect(supportsImageGen(grok)).toBe(true);
|
|
86
|
+
expect(supportsImageGen(gemini)).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('lists only image-capable seats when imageOnly', () => {
|
|
90
|
+
const lines = listModelHelpLines({ imageOnly: true }).join('\n');
|
|
91
|
+
expect(lines).toContain('xai-grok/grok-4.5');
|
|
92
|
+
expect(lines).toContain('openai-codex/gpt-5.6-sol');
|
|
93
|
+
expect(lines).not.toContain('google-antigravity/gemini-3.6-flash');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('formats image-gen model errors with capable seats only', () => {
|
|
97
|
+
const gemini = resolveModel('google-antigravity/gemini-3.6-flash');
|
|
98
|
+
if (!gemini) throw new Error('gemini resolution failed');
|
|
99
|
+
const err = formatImageGenModelError('google-antigravity/gemini-3.6-flash', gemini);
|
|
100
|
+
expect(err).toContain('cannot generate images');
|
|
101
|
+
expect(err).toContain('backend "agy"');
|
|
102
|
+
expect(err).toContain('xai-grok/grok-4.5');
|
|
103
|
+
expect(err).toContain('openai-codex/gpt-5.6-sol');
|
|
104
|
+
const seatsSection = err.slice(err.indexOf('Image-gen seats'));
|
|
105
|
+
expect(seatsSection).not.toContain('google-antigravity/gemini-3.6-flash');
|
|
106
|
+
});
|
|
107
|
+
});
|
package/src/models.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical model registry and resolution for aibridge.
|
|
3
|
+
*
|
|
4
|
+
* Models are registered by canonical, provider-qualified slug —
|
|
5
|
+
* `<vendor>-<cli>/<model>[-<effort>]`, e.g. `openai-codex/gpt-5.6-sol-high`.
|
|
6
|
+
* Canonical slugs only — no short aliases, by design.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type Backend = 'agy' | 'claude' | 'codex' | 'grok';
|
|
10
|
+
export type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
11
|
+
|
|
12
|
+
export interface ModelSpec {
|
|
13
|
+
readonly slug: string; // canonical, effort-less
|
|
14
|
+
readonly backend: Backend;
|
|
15
|
+
readonly backendModel: string | undefined; // undefined = CLI default
|
|
16
|
+
readonly efforts: readonly Effort[] | null;
|
|
17
|
+
readonly defaultEffort?: Effort; // only when backend REQUIRES one (agy gemini)
|
|
18
|
+
readonly brief: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ResolvedModel {
|
|
22
|
+
readonly spec: ModelSpec;
|
|
23
|
+
readonly effort: Effort | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const MODELS: Record<string, ModelSpec> = {
|
|
27
|
+
'xai-grok/grok-4.5': {
|
|
28
|
+
slug: 'xai-grok/grok-4.5',
|
|
29
|
+
backend: 'grok',
|
|
30
|
+
backendModel: 'grok-4.5',
|
|
31
|
+
efforts: ['low', 'medium', 'high'],
|
|
32
|
+
brief: 'xAI Grok 4.5 via grok CLI — default for plan & review; off-budget',
|
|
33
|
+
},
|
|
34
|
+
'google-antigravity/gemini-3.6-flash': {
|
|
35
|
+
slug: 'google-antigravity/gemini-3.6-flash',
|
|
36
|
+
backend: 'agy',
|
|
37
|
+
backendModel: 'gemini-3.6-flash',
|
|
38
|
+
efforts: ['low', 'medium', 'high'],
|
|
39
|
+
defaultEffort: 'high',
|
|
40
|
+
brief: 'Google Gemini 3.6 Flash via agy — default for implement; off-budget',
|
|
41
|
+
},
|
|
42
|
+
'google-antigravity/claude-sonnet-4-6': {
|
|
43
|
+
slug: 'google-antigravity/claude-sonnet-4-6',
|
|
44
|
+
backend: 'agy',
|
|
45
|
+
backendModel: 'claude-sonnet-4-6',
|
|
46
|
+
efforts: null,
|
|
47
|
+
brief: 'Claude Sonnet 4.6 (thinking) via agy — off-budget',
|
|
48
|
+
},
|
|
49
|
+
'google-antigravity/claude-opus-4-6-thinking': {
|
|
50
|
+
slug: 'google-antigravity/claude-opus-4-6-thinking',
|
|
51
|
+
backend: 'agy',
|
|
52
|
+
backendModel: 'claude-opus-4-6-thinking',
|
|
53
|
+
efforts: null,
|
|
54
|
+
brief: 'Claude Opus 4.6 (thinking) via agy — off-budget heavyweight',
|
|
55
|
+
},
|
|
56
|
+
'google-antigravity/gpt-oss-120b-medium': {
|
|
57
|
+
slug: 'google-antigravity/gpt-oss-120b-medium',
|
|
58
|
+
backend: 'agy',
|
|
59
|
+
backendModel: 'gpt-oss-120b-medium',
|
|
60
|
+
efforts: null,
|
|
61
|
+
brief: 'GPT-OSS 120B (medium) via agy — off-budget',
|
|
62
|
+
},
|
|
63
|
+
'openai-codex/gpt-5.6-sol': {
|
|
64
|
+
slug: 'openai-codex/gpt-5.6-sol',
|
|
65
|
+
backend: 'codex',
|
|
66
|
+
backendModel: 'gpt-5.6-sol',
|
|
67
|
+
efforts: ['low', 'medium', 'high', 'xhigh'],
|
|
68
|
+
brief: 'OpenAI Codex gpt-5.6-sol via codex CLI',
|
|
69
|
+
},
|
|
70
|
+
'anthropic-claude/sonnet': {
|
|
71
|
+
slug: 'anthropic-claude/sonnet',
|
|
72
|
+
backend: 'claude',
|
|
73
|
+
backendModel: 'sonnet',
|
|
74
|
+
efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
|
75
|
+
brief: 'Claude Sonnet via claude CLI — bills your Claude subscription',
|
|
76
|
+
},
|
|
77
|
+
'anthropic-claude/opus': {
|
|
78
|
+
slug: 'anthropic-claude/opus',
|
|
79
|
+
backend: 'claude',
|
|
80
|
+
backendModel: 'opus',
|
|
81
|
+
efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
|
|
82
|
+
defaultEffort: 'high',
|
|
83
|
+
brief: 'Claude Opus via claude CLI (default effort: high) — bills subscription',
|
|
84
|
+
},
|
|
85
|
+
} as const satisfies Record<string, ModelSpec>;
|
|
86
|
+
|
|
87
|
+
export const DEFAULT_MODEL = 'xai-grok/grok-4.5';
|
|
88
|
+
export const DEFAULT_IMPLEMENTER = 'google-antigravity/gemini-3.6-flash';
|
|
89
|
+
export const DEFAULT_IMAGE_GEN = 'openai-codex/gpt-5.6-sol';
|
|
90
|
+
|
|
91
|
+
const IMAGE_GEN_BACKENDS: ReadonlySet<Backend> = new Set(['codex', 'grok']);
|
|
92
|
+
|
|
93
|
+
export function supportsImageGen(resolved: ResolvedModel): boolean {
|
|
94
|
+
return IMAGE_GEN_BACKENDS.has(resolved.spec.backend);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const EFFORTS_SET: ReadonlySet<string> = new Set<Effort>(['low', 'medium', 'high', 'xhigh', 'max']);
|
|
98
|
+
|
|
99
|
+
export function resolveModel(input: string): ResolvedModel | undefined {
|
|
100
|
+
if (MODELS[input]) {
|
|
101
|
+
const spec = MODELS[input];
|
|
102
|
+
return { spec, effort: spec.defaultEffort };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const lastDashIdx = input.lastIndexOf('-');
|
|
106
|
+
if (lastDashIdx > 0) {
|
|
107
|
+
const prefix = input.slice(0, lastDashIdx);
|
|
108
|
+
const token = input.slice(lastDashIdx + 1);
|
|
109
|
+
|
|
110
|
+
const spec = MODELS[prefix];
|
|
111
|
+
|
|
112
|
+
if (
|
|
113
|
+
spec &&
|
|
114
|
+
EFFORTS_SET.has(token) &&
|
|
115
|
+
spec.efforts &&
|
|
116
|
+
(spec.efforts as readonly string[]).includes(token)
|
|
117
|
+
) {
|
|
118
|
+
return { spec, effort: token as Effort };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function backendModelId(resolved: ResolvedModel): string | undefined {
|
|
126
|
+
if (!resolved.spec.backendModel) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
if (resolved.spec.backend === 'agy') {
|
|
130
|
+
const effort = resolved.effort ?? resolved.spec.defaultEffort;
|
|
131
|
+
if (effort) {
|
|
132
|
+
return `${resolved.spec.backendModel}-${effort}`;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return resolved.spec.backendModel;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function listModelHelpLines(opts: { readonly imageOnly?: boolean } = {}): string[] {
|
|
139
|
+
const lines: string[] = [];
|
|
140
|
+
for (const [slug, spec] of Object.entries(MODELS)) {
|
|
141
|
+
if (opts.imageOnly && !IMAGE_GEN_BACKENDS.has(spec.backend)) continue;
|
|
142
|
+
lines.push(` ${slug}`);
|
|
143
|
+
lines.push(` ${spec.brief}`);
|
|
144
|
+
}
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function formatUnknownModelError(input: string): string {
|
|
149
|
+
const lines = [`Unknown model "${input}".`, 'Available models:', ...listModelHelpLines()];
|
|
150
|
+
return lines.join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function formatImageGenModelError(input: string, resolved: ResolvedModel): string {
|
|
154
|
+
return [
|
|
155
|
+
`Model "${input}" (${resolved.spec.slug}) cannot generate images — backend "${resolved.spec.backend}" has no image path.`,
|
|
156
|
+
'Image-gen seats (canonical slug):',
|
|
157
|
+
...listModelHelpLines({ imageOnly: true }),
|
|
158
|
+
].join('\n');
|
|
159
|
+
}
|
package/src/parsers.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stricli `parse` functions (string -> T). Throwing inside one makes stricli
|
|
3
|
+
* reject the argument up-front with a clean, flag-named error — instead of
|
|
4
|
+
* silently letting a bad value (NaN, Infinity, "") flow into an impl where it
|
|
5
|
+
* gets masked or, worse, breaks `setTimeout`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function positiveIntSeconds(input: string): number {
|
|
9
|
+
const n = Number(input);
|
|
10
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
11
|
+
throw new RangeError(`expected a positive whole number of seconds, got "${input}"`);
|
|
12
|
+
}
|
|
13
|
+
if (n > 86_400) {
|
|
14
|
+
throw new RangeError(`timeout too large: ${n}s (max 86400 = 24h)`);
|
|
15
|
+
}
|
|
16
|
+
return n;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function nonEmptyPrompt(input: string): string {
|
|
20
|
+
if (input.trim().length === 0) {
|
|
21
|
+
throw new Error('prompt must not be empty');
|
|
22
|
+
}
|
|
23
|
+
return input;
|
|
24
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import type { AgyQuotaSnapshot } from '@aibridge/agy';
|
|
3
|
+
import type { CodexQuotaSnapshot } from '@aibridge/codex';
|
|
4
|
+
import { test } from 'vitest';
|
|
5
|
+
import { evaluateAgyPreflight, evaluateCodexPreflight } from './quotaPreflight.ts';
|
|
6
|
+
|
|
7
|
+
test('evaluateAgyPreflight: exhausted model returns ok:false with resetTime', () => {
|
|
8
|
+
const snapshot: AgyQuotaSnapshot = {
|
|
9
|
+
fetchedAt: '2024-01-01T12:00:00Z',
|
|
10
|
+
groups: [],
|
|
11
|
+
models: [
|
|
12
|
+
{
|
|
13
|
+
modelId: 'test-model-id',
|
|
14
|
+
label: 'Gemini 3.5 Flash (High)',
|
|
15
|
+
remainingFraction: 0,
|
|
16
|
+
exhausted: true,
|
|
17
|
+
resetTime: '2024-01-02T12:00:00Z',
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const result = evaluateAgyPreflight(snapshot, 'Gemini 3.5 Flash (High)');
|
|
23
|
+
|
|
24
|
+
assert.deepEqual(result, {
|
|
25
|
+
ok: false,
|
|
26
|
+
message: 'agy model "Gemini 3.5 Flash (High)" is quota-exhausted',
|
|
27
|
+
resetAt: '2024-01-02T12:00:00Z',
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('evaluateAgyPreflight: exhausted model with undefined resetTime falls back to Gemini group', () => {
|
|
32
|
+
const snapshot: AgyQuotaSnapshot = {
|
|
33
|
+
fetchedAt: '2024-01-01T12:00:00Z',
|
|
34
|
+
groups: [
|
|
35
|
+
{
|
|
36
|
+
displayName: 'Gemini 3.5 Flash',
|
|
37
|
+
description: undefined,
|
|
38
|
+
buckets: [
|
|
39
|
+
{
|
|
40
|
+
bucketId: 'weekly',
|
|
41
|
+
displayName: 'weekly',
|
|
42
|
+
window: 'weekly',
|
|
43
|
+
remainingFraction: 0,
|
|
44
|
+
resetTime: '2024-01-08T12:00:00Z',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
bucketId: '5h',
|
|
48
|
+
displayName: '5h',
|
|
49
|
+
window: '5h',
|
|
50
|
+
remainingFraction: 0,
|
|
51
|
+
resetTime: '2024-01-01T17:00:00Z',
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
models: [
|
|
57
|
+
{
|
|
58
|
+
modelId: 'test-model-id',
|
|
59
|
+
label: 'Gemini 3.5 Flash (High)',
|
|
60
|
+
remainingFraction: 0,
|
|
61
|
+
exhausted: true,
|
|
62
|
+
resetTime: undefined,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const result = evaluateAgyPreflight(snapshot, 'Gemini 3.5 Flash (High)');
|
|
68
|
+
|
|
69
|
+
assert.strictEqual(result.ok, false);
|
|
70
|
+
assert.strictEqual(result.message, 'agy model "Gemini 3.5 Flash (High)" is quota-exhausted');
|
|
71
|
+
assert.strictEqual(result.resetAt, '2024-01-01T17:00:00Z');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('evaluateAgyPreflight: healthy model returns ok:true', () => {
|
|
75
|
+
const snapshot: AgyQuotaSnapshot = {
|
|
76
|
+
fetchedAt: '2024-01-01T12:00:00Z',
|
|
77
|
+
groups: [],
|
|
78
|
+
models: [
|
|
79
|
+
{
|
|
80
|
+
modelId: 'test-model-id',
|
|
81
|
+
label: 'Gemini 3.5 Flash (Low)',
|
|
82
|
+
remainingFraction: 0.5,
|
|
83
|
+
exhausted: false,
|
|
84
|
+
resetTime: undefined,
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const result = evaluateAgyPreflight(snapshot, 'Gemini 3.5 Flash (Low)');
|
|
90
|
+
|
|
91
|
+
assert.deepEqual(result, { ok: true });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('evaluateAgyPreflight: model not found returns ok:true with warning', () => {
|
|
95
|
+
const snapshot: AgyQuotaSnapshot = {
|
|
96
|
+
fetchedAt: '2024-01-01T12:00:00Z',
|
|
97
|
+
groups: [],
|
|
98
|
+
models: [],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const result = evaluateAgyPreflight(snapshot, 'NonExistent Model');
|
|
102
|
+
|
|
103
|
+
assert.strictEqual(result.ok, true);
|
|
104
|
+
assert.strictEqual(result.warning, 'model "NonExistent Model" not in quota snapshot; proceeding');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('evaluateCodexPreflight: limitReached returns ok:false', () => {
|
|
108
|
+
const snapshot: CodexQuotaSnapshot = {
|
|
109
|
+
fetchedAt: '2024-01-01T12:00:00Z',
|
|
110
|
+
planType: 'pro',
|
|
111
|
+
limitReached: true,
|
|
112
|
+
windows: [
|
|
113
|
+
{
|
|
114
|
+
window: '5h',
|
|
115
|
+
usedPercent: 100,
|
|
116
|
+
resetAt: '2024-01-01T17:00:00Z',
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const result = evaluateCodexPreflight(snapshot);
|
|
122
|
+
|
|
123
|
+
assert.deepEqual(result, {
|
|
124
|
+
ok: false,
|
|
125
|
+
message: 'codex quota limit reached',
|
|
126
|
+
resetAt: '2024-01-01T17:00:00Z',
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('evaluateCodexPreflight: window at usedPercent 100 returns ok:false', () => {
|
|
131
|
+
const snapshot: CodexQuotaSnapshot = {
|
|
132
|
+
fetchedAt: '2024-01-01T12:00:00Z',
|
|
133
|
+
planType: 'pro',
|
|
134
|
+
limitReached: false,
|
|
135
|
+
windows: [
|
|
136
|
+
{
|
|
137
|
+
window: '5h',
|
|
138
|
+
usedPercent: 100,
|
|
139
|
+
resetAt: '2024-01-01T17:00:00Z',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
window: 'weekly',
|
|
143
|
+
usedPercent: 50,
|
|
144
|
+
resetAt: '2024-01-08T12:00:00Z',
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const result = evaluateCodexPreflight(snapshot);
|
|
150
|
+
|
|
151
|
+
assert.strictEqual(result.ok, false);
|
|
152
|
+
assert.strictEqual(result.message, 'codex quota limit reached');
|
|
153
|
+
assert.strictEqual(result.resetAt, '2024-01-01T17:00:00Z');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('evaluateCodexPreflight: healthy (77% used) returns ok:true', () => {
|
|
157
|
+
const snapshot: CodexQuotaSnapshot = {
|
|
158
|
+
fetchedAt: '2024-01-01T12:00:00Z',
|
|
159
|
+
planType: 'pro',
|
|
160
|
+
limitReached: false,
|
|
161
|
+
windows: [
|
|
162
|
+
{
|
|
163
|
+
window: '5h',
|
|
164
|
+
usedPercent: 77,
|
|
165
|
+
resetAt: '2024-01-01T17:00:00Z',
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
window: 'weekly',
|
|
169
|
+
usedPercent: 50,
|
|
170
|
+
resetAt: '2024-01-08T12:00:00Z',
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const result = evaluateCodexPreflight(snapshot);
|
|
176
|
+
|
|
177
|
+
assert.deepEqual(result, { ok: true });
|
|
178
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { type AgyQuotaSnapshot, fetchAgyQuota, findModelQuota } from '@aibridge/agy';
|
|
2
|
+
import { type CodexQuotaSnapshot, fetchCodexQuota } from '@aibridge/codex';
|
|
3
|
+
import { backendModelId, type ResolvedModel } from './models.ts';
|
|
4
|
+
|
|
5
|
+
export type PreflightVerdict =
|
|
6
|
+
| { readonly ok: true; readonly warning?: string }
|
|
7
|
+
| { readonly ok: false; readonly message: string; readonly resetAt: string | undefined };
|
|
8
|
+
|
|
9
|
+
export function evaluateAgyPreflight(
|
|
10
|
+
snapshot: AgyQuotaSnapshot,
|
|
11
|
+
backendModel: string,
|
|
12
|
+
): PreflightVerdict {
|
|
13
|
+
const quota = findModelQuota(snapshot, backendModel);
|
|
14
|
+
|
|
15
|
+
if (!quota) {
|
|
16
|
+
return { ok: true, warning: `model "${backendModel}" not in quota snapshot; proceeding` };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (quota.exhausted) {
|
|
20
|
+
let resetAt = quota.resetTime;
|
|
21
|
+
if (!resetAt) {
|
|
22
|
+
for (const group of snapshot.groups) {
|
|
23
|
+
if (group.displayName.includes('Gemini')) {
|
|
24
|
+
for (const bucket of group.buckets) {
|
|
25
|
+
if (bucket.resetTime) {
|
|
26
|
+
if (!resetAt || new Date(bucket.resetTime).getTime() < new Date(resetAt).getTime()) {
|
|
27
|
+
resetAt = bucket.resetTime;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { ok: false, message: `agy model "${backendModel}" is quota-exhausted`, resetAt };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { ok: true };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function evaluateCodexPreflight(snapshot: CodexQuotaSnapshot): PreflightVerdict {
|
|
41
|
+
if (snapshot.limitReached) {
|
|
42
|
+
const resetAt = snapshot.windows.find(w => w.resetAt)?.resetAt;
|
|
43
|
+
return { ok: false, message: 'codex quota limit reached', resetAt };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const exhaustedWindow = snapshot.windows.find(w => w.usedPercent >= 100);
|
|
47
|
+
if (exhaustedWindow) {
|
|
48
|
+
return { ok: false, message: 'codex quota limit reached', resetAt: exhaustedWindow.resetAt };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { ok: true };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function preflightModel(resolved: ResolvedModel): Promise<PreflightVerdict> {
|
|
55
|
+
if (resolved.spec.backend === 'codex') {
|
|
56
|
+
return preflightCodex();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (resolved.spec.backend !== 'agy') {
|
|
60
|
+
return { ok: true };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const snapshot = await fetchAgyQuota();
|
|
65
|
+
const modelId = backendModelId(resolved) ?? '';
|
|
66
|
+
return evaluateAgyPreflight(snapshot, modelId);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
warning: `quota preflight failed (${(err as Error).message}); proceeding`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function preflightCodex(): Promise<PreflightVerdict> {
|
|
76
|
+
try {
|
|
77
|
+
const snapshot = await fetchCodexQuota();
|
|
78
|
+
return evaluateCodexPreflight(snapshot);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
warning: `quota preflight failed (${(err as Error).message}); proceeding`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function formatReset(resetTime: string | undefined): string {
|
|
88
|
+
if (!resetTime) return '-';
|
|
89
|
+
const ms = new Date(resetTime).getTime() - Date.now();
|
|
90
|
+
if (Number.isNaN(ms)) return resetTime;
|
|
91
|
+
if (ms <= 0) return 'now';
|
|
92
|
+
const mins = Math.round(ms / 60_000);
|
|
93
|
+
const rel = mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h${mins % 60}m`;
|
|
94
|
+
return `${new Date(resetTime).toLocaleTimeString()} (in ${rel})`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function renderPreflightRefusal(
|
|
98
|
+
cmd: string,
|
|
99
|
+
verdict: { message: string; resetAt: string | undefined },
|
|
100
|
+
): string {
|
|
101
|
+
const resetClause = verdict.resetAt ? ` Resets ${formatReset(verdict.resetAt)}.` : '';
|
|
102
|
+
return `aibridge ${cmd}: refusing — ${verdict.message}.${resetClause} Use --no-preflight to override, or a claude-backend fallback (subagent --model sonnet|opus — bills the Claude subscription).`;
|
|
103
|
+
}
|