termi-kids 0.1.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.
- package/LICENSE +34 -0
- package/README.md +148 -0
- package/SAFETY.md +187 -0
- package/bin/termi.js +22 -0
- package/dist/agent/context.js +126 -0
- package/dist/agent/loop.js +172 -0
- package/dist/agent/prompts/system.js +45 -0
- package/dist/agent/tools.js +335 -0
- package/dist/auth/keychain.js +146 -0
- package/dist/auth/oauth.js +375 -0
- package/dist/auth/tokens.js +219 -0
- package/dist/cli.js +258 -0
- package/dist/config/paths.js +92 -0
- package/dist/config/pin.js +150 -0
- package/dist/config/settings.js +131 -0
- package/dist/grownups/panel.js +483 -0
- package/dist/learn/lessons.js +490 -0
- package/dist/learn/runner.js +193 -0
- package/dist/preview/server.js +407 -0
- package/dist/projects/create.js +103 -0
- package/dist/projects/ideas.js +182 -0
- package/dist/projects/quests.js +277 -0
- package/dist/projects/scaffolds/art.js +484 -0
- package/dist/projects/scaffolds/biggames.js +554 -0
- package/dist/projects/scaffolds/characters.js +580 -0
- package/dist/projects/scaffolds/games.js +516 -0
- package/dist/projects/scaffolds/index.js +24 -0
- package/dist/projects/scaffolds/music.js +528 -0
- package/dist/projects/scaffolds/pets.js +567 -0
- package/dist/projects/scaffolds/quizzes.js +757 -0
- package/dist/projects/scaffolds/stories.js +620 -0
- package/dist/projects/scaffolds/vendor/KAPLAY-LICENSE.txt +35 -0
- package/dist/projects/scaffolds/vendor/kaplay.mjs +57 -0
- package/dist/projects/scaffolds/websites.js +474 -0
- package/dist/projects/snapshots.js +203 -0
- package/dist/projects/store.js +325 -0
- package/dist/providers/errors.js +207 -0
- package/dist/providers/index.js +316 -0
- package/dist/providers/models.js +38 -0
- package/dist/safety/audit.js +195 -0
- package/dist/safety/blocks.js +29 -0
- package/dist/safety/classifier.js +337 -0
- package/dist/safety/codescan.js +168 -0
- package/dist/safety/guarddownload.js +79 -0
- package/dist/safety/guardrunner.js +125 -0
- package/dist/safety/localguard.js +227 -0
- package/dist/safety/modelstore.js +127 -0
- package/dist/safety/prefilter.js +214 -0
- package/dist/safety/session.js +118 -0
- package/dist/safety/taxonomy.js +246 -0
- package/dist/safety/textextract.js +193 -0
- package/dist/setup/launcher.js +65 -0
- package/dist/setup/wizard.js +469 -0
- package/dist/surfaces/chat.js +439 -0
- package/dist/surfaces/commands.js +206 -0
- package/dist/surfaces/home.js +438 -0
- package/dist/types.js +5 -0
- package/dist/ui/banner.js +35 -0
- package/dist/ui/celebrate.js +141 -0
- package/dist/ui/errors.js +97 -0
- package/dist/ui/mascot.js +223 -0
- package/dist/ui/text.js +156 -0
- package/dist/ui/theme.js +92 -0
- package/package.json +67 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project store: open kid projects, list them, and work with their files.
|
|
3
|
+
*
|
|
4
|
+
* Each project lives at projectsDir()/<slug>/ and holds:
|
|
5
|
+
* - .termi.json project metadata (ProjectMeta, written atomically)
|
|
6
|
+
* - TERMI.md the project notes the model reads and updates
|
|
7
|
+
* - kid files index.html, style.css, game.js, and friends
|
|
8
|
+
* - vendor files engine files like kaplay.mjs, never counted as kid files
|
|
9
|
+
*
|
|
10
|
+
* All file access is jailed to the project directory. Paths are resolved
|
|
11
|
+
* and prefix-checked, with case folding on Windows.
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { atomicWriteFileSync, projectsDir } from '../config/paths.js';
|
|
16
|
+
/** The metadata file inside every project directory. */
|
|
17
|
+
export const metaFileName = '.termi.json';
|
|
18
|
+
/** The notes file the model keeps for the kid. Not a kid file. */
|
|
19
|
+
export const notesFileName = 'TERMI.md';
|
|
20
|
+
/** Vendored engine files. Present on disk, never counted or touched as kid files. */
|
|
21
|
+
const vendorEngineFiles = new Set(['kaplay.mjs']);
|
|
22
|
+
/** Largest size a single kid file may be, in bytes. */
|
|
23
|
+
export const maxKidFileBytes = 256 * 1024;
|
|
24
|
+
/** TERMI.md never grows past this many lines. */
|
|
25
|
+
export const maxTermiMdLines = 60;
|
|
26
|
+
const outsideProjectMessage = 'That file is outside your project. Termi will not touch it.';
|
|
27
|
+
const specialNameMessage = 'That file name is off limits. Pick a different name.';
|
|
28
|
+
const tooLargeMessage = 'That file is too big. Keep each file under 256 KB.';
|
|
29
|
+
function caseFold(p) {
|
|
30
|
+
return process.platform === 'win32' ? p.toLowerCase() : p;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolves a relative path inside the project directory.
|
|
34
|
+
* Returns null when the path is absolute, empty, or escapes the project.
|
|
35
|
+
*/
|
|
36
|
+
function resolveInside(dir, relPath) {
|
|
37
|
+
if (typeof relPath !== 'string' || relPath.trim().length === 0)
|
|
38
|
+
return null;
|
|
39
|
+
if (path.isAbsolute(relPath))
|
|
40
|
+
return null;
|
|
41
|
+
const base = path.resolve(dir);
|
|
42
|
+
const resolved = path.resolve(base, relPath);
|
|
43
|
+
if (caseFold(resolved) === caseFold(base))
|
|
44
|
+
return null;
|
|
45
|
+
if (!caseFold(resolved).startsWith(caseFold(base + path.sep)))
|
|
46
|
+
return null;
|
|
47
|
+
return resolved;
|
|
48
|
+
}
|
|
49
|
+
/** Real path segments, with empty and "." entries dropped. */
|
|
50
|
+
function segmentsOf(relPath) {
|
|
51
|
+
return relPath.split(/[\\/]/).filter((s) => s.length > 0 && s !== '.');
|
|
52
|
+
}
|
|
53
|
+
/** True for dotfiles (like .termi.json) anywhere in the path. */
|
|
54
|
+
function hasDotSegment(relPath) {
|
|
55
|
+
return segmentsOf(relPath).some((s) => s.startsWith('.'));
|
|
56
|
+
}
|
|
57
|
+
/** True for vendored engine files like kaplay.mjs. */
|
|
58
|
+
export function isVendorFile(relPath) {
|
|
59
|
+
const segs = segmentsOf(relPath);
|
|
60
|
+
const last = segs.length > 0 ? segs[segs.length - 1] : undefined;
|
|
61
|
+
return last !== undefined && vendorEngineFiles.has(last);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Lists kid files under a directory. Excludes TERMI.md, dotfiles
|
|
65
|
+
* (which covers .termi.json), and vendored engine files.
|
|
66
|
+
*/
|
|
67
|
+
function walkKidFiles(dir) {
|
|
68
|
+
const out = [];
|
|
69
|
+
const visit = (current, relParts) => {
|
|
70
|
+
let entries;
|
|
71
|
+
try {
|
|
72
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (entry.name.startsWith('.'))
|
|
79
|
+
continue;
|
|
80
|
+
const childParts = [...relParts, entry.name];
|
|
81
|
+
const childAbs = path.join(current, entry.name);
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
visit(childAbs, childParts);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!entry.isFile())
|
|
87
|
+
continue;
|
|
88
|
+
const relPath = childParts.join('/');
|
|
89
|
+
if (relPath === notesFileName)
|
|
90
|
+
continue;
|
|
91
|
+
if (vendorEngineFiles.has(relPath))
|
|
92
|
+
continue;
|
|
93
|
+
let bytes = 0;
|
|
94
|
+
try {
|
|
95
|
+
bytes = fs.statSync(childAbs).size;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
out.push({ relPath, bytes });
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
visit(dir, []);
|
|
104
|
+
out.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
/** Flattens a value to one safe line. Leading #, - and > marks are stripped. */
|
|
108
|
+
function sanitizeLine(value) {
|
|
109
|
+
return value
|
|
110
|
+
.split(/\r?\n/)
|
|
111
|
+
.map((line) => line.replace(/^[\s#>-]+/, '').trim())
|
|
112
|
+
.filter((line) => line.length > 0)
|
|
113
|
+
.join(' ')
|
|
114
|
+
.replace(/\s+/g, ' ')
|
|
115
|
+
.trim();
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Renders TERMI.md with the exact template headings.
|
|
119
|
+
* Field text can never break the heading structure, and the result
|
|
120
|
+
* stays at or under maxTermiMdLines. Oldest "Built so far" bullets
|
|
121
|
+
* are dropped first when space runs out.
|
|
122
|
+
*/
|
|
123
|
+
export function renderTermiMd(prettyName, fields) {
|
|
124
|
+
const title = sanitizeLine(prettyName) || 'My Project';
|
|
125
|
+
const what = sanitizeLine(fields.whatThisIs) || 'A Termi project.';
|
|
126
|
+
let files = fields.files.map(sanitizeLine).filter((f) => f.length > 0);
|
|
127
|
+
let built = fields.builtSoFar.map(sanitizeLine).filter((b) => b.length > 0);
|
|
128
|
+
if (built.length === 0)
|
|
129
|
+
built = ['Just getting started.'];
|
|
130
|
+
const recap = sanitizeLine(fields.recapLine) || 'We are just getting started.';
|
|
131
|
+
const compose = () => [
|
|
132
|
+
`# ${title}`,
|
|
133
|
+
'',
|
|
134
|
+
'## What this is',
|
|
135
|
+
what,
|
|
136
|
+
'',
|
|
137
|
+
'## Files',
|
|
138
|
+
...files.map((f) => `- ${f}`),
|
|
139
|
+
'',
|
|
140
|
+
'## Built so far',
|
|
141
|
+
...built.map((b) => `- ${b}`),
|
|
142
|
+
'',
|
|
143
|
+
'## Recap line',
|
|
144
|
+
recap,
|
|
145
|
+
'',
|
|
146
|
+
];
|
|
147
|
+
let lines = compose();
|
|
148
|
+
while (lines.length > maxTermiMdLines && built.length > 1) {
|
|
149
|
+
built = built.slice(1);
|
|
150
|
+
lines = compose();
|
|
151
|
+
}
|
|
152
|
+
while (lines.length > maxTermiMdLines && files.length > 1) {
|
|
153
|
+
files = files.slice(0, -1);
|
|
154
|
+
lines = compose();
|
|
155
|
+
}
|
|
156
|
+
return lines.join('\n');
|
|
157
|
+
}
|
|
158
|
+
/** Pulls the template fields back out of TERMI.md text. */
|
|
159
|
+
export function parseTermiMd(content) {
|
|
160
|
+
const sections = new Map();
|
|
161
|
+
let current = null;
|
|
162
|
+
for (const line of content.split(/\r?\n/)) {
|
|
163
|
+
const heading = /^##\s+(.+)$/.exec(line);
|
|
164
|
+
if (heading && heading[1] !== undefined) {
|
|
165
|
+
current = heading[1].trim().toLowerCase();
|
|
166
|
+
if (!sections.has(current))
|
|
167
|
+
sections.set(current, []);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (/^#\s/.test(line))
|
|
171
|
+
continue;
|
|
172
|
+
if (current !== null)
|
|
173
|
+
sections.get(current)?.push(line);
|
|
174
|
+
}
|
|
175
|
+
const grab = (name) => (sections.get(name) ?? []).map((l) => l.trim()).filter((l) => l.length > 0);
|
|
176
|
+
const stripBullet = (l) => l.replace(/^-\s*/, '').trim();
|
|
177
|
+
return {
|
|
178
|
+
whatThisIs: grab('what this is').join(' '),
|
|
179
|
+
files: grab('files').map(stripBullet),
|
|
180
|
+
builtSoFar: grab('built so far').map(stripBullet),
|
|
181
|
+
recapLine: grab('recap line').join(' '),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/** Writes a project's metadata file atomically. */
|
|
185
|
+
export function saveProjectMeta(meta) {
|
|
186
|
+
const target = path.join(projectsDir(), meta.slug, metaFileName);
|
|
187
|
+
atomicWriteFileSync(target, JSON.stringify(meta, null, 2) + '\n');
|
|
188
|
+
}
|
|
189
|
+
const metaKeys = ['slug', 'prettyName', 'scaffoldId', 'themeId', 'createdAt', 'lastOpenedAt'];
|
|
190
|
+
function loadMeta(slug) {
|
|
191
|
+
const metaPath = path.join(projectsDir(), slug, metaFileName);
|
|
192
|
+
let raw;
|
|
193
|
+
try {
|
|
194
|
+
raw = fs.readFileSync(metaPath, 'utf8');
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
let data;
|
|
200
|
+
try {
|
|
201
|
+
data = JSON.parse(raw);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
if (typeof data !== 'object' || data === null)
|
|
207
|
+
return null;
|
|
208
|
+
const record = data;
|
|
209
|
+
for (const key of metaKeys) {
|
|
210
|
+
const value = record[key];
|
|
211
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
slug, // the directory name is the truth for the slug
|
|
216
|
+
prettyName: record.prettyName,
|
|
217
|
+
scaffoldId: record.scaffoldId,
|
|
218
|
+
themeId: record.themeId,
|
|
219
|
+
createdAt: record.createdAt,
|
|
220
|
+
lastOpenedAt: record.lastOpenedAt,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function makeContext(meta) {
|
|
224
|
+
const dir = path.join(projectsDir(), meta.slug);
|
|
225
|
+
const listKidFiles = () => walkKidFiles(dir);
|
|
226
|
+
const readFile = (relPath) => {
|
|
227
|
+
const target = resolveInside(dir, relPath);
|
|
228
|
+
if (target === null)
|
|
229
|
+
return null;
|
|
230
|
+
if (hasDotSegment(relPath))
|
|
231
|
+
return null;
|
|
232
|
+
try {
|
|
233
|
+
if (!fs.statSync(target).isFile())
|
|
234
|
+
return null;
|
|
235
|
+
return fs.readFileSync(target, 'utf8');
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
const writeFile = (relPath, content) => {
|
|
242
|
+
const target = resolveInside(dir, relPath);
|
|
243
|
+
if (target === null)
|
|
244
|
+
throw new Error(outsideProjectMessage);
|
|
245
|
+
if (hasDotSegment(relPath) || isVendorFile(relPath))
|
|
246
|
+
throw new Error(specialNameMessage);
|
|
247
|
+
if (Buffer.byteLength(content, 'utf8') > maxKidFileBytes)
|
|
248
|
+
throw new Error(tooLargeMessage);
|
|
249
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
250
|
+
fs.writeFileSync(target, content, 'utf8');
|
|
251
|
+
};
|
|
252
|
+
const readTermiMd = () => {
|
|
253
|
+
try {
|
|
254
|
+
return fs.readFileSync(path.join(dir, notesFileName), 'utf8');
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
return renderTermiMd(meta.prettyName, {
|
|
258
|
+
whatThisIs: '',
|
|
259
|
+
files: listKidFiles().map((f) => f.relPath),
|
|
260
|
+
builtSoFar: [],
|
|
261
|
+
recapLine: '',
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
const updateTermiMd = (fields) => {
|
|
266
|
+
const current = parseTermiMd(readTermiMd());
|
|
267
|
+
if (current.files.length === 0) {
|
|
268
|
+
current.files = listKidFiles().map((f) => f.relPath);
|
|
269
|
+
}
|
|
270
|
+
if (fields.whatThisIs !== undefined)
|
|
271
|
+
current.whatThisIs = fields.whatThisIs;
|
|
272
|
+
if (fields.builtSoFar !== undefined) {
|
|
273
|
+
for (const item of fields.builtSoFar) {
|
|
274
|
+
const clean = sanitizeLine(item);
|
|
275
|
+
if (clean.length > 0 && !current.builtSoFar.includes(clean)) {
|
|
276
|
+
current.builtSoFar.push(clean);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (fields.recapLine !== undefined)
|
|
281
|
+
current.recapLine = fields.recapLine;
|
|
282
|
+
atomicWriteFileSync(path.join(dir, notesFileName), renderTermiMd(meta.prettyName, current));
|
|
283
|
+
};
|
|
284
|
+
const touch = () => {
|
|
285
|
+
meta.lastOpenedAt = new Date().toISOString();
|
|
286
|
+
saveProjectMeta(meta);
|
|
287
|
+
};
|
|
288
|
+
return { meta, dir, listKidFiles, readFile, writeFile, readTermiMd, updateTermiMd, touch };
|
|
289
|
+
}
|
|
290
|
+
/** Opens a project by slug. Returns null when it is missing or broken. */
|
|
291
|
+
export function openProject(slug) {
|
|
292
|
+
if (typeof slug !== 'string' || slug.length === 0)
|
|
293
|
+
return null;
|
|
294
|
+
if (slug.includes('/') || slug.includes('\\') || slug.startsWith('.'))
|
|
295
|
+
return null;
|
|
296
|
+
const meta = loadMeta(slug);
|
|
297
|
+
if (meta === null)
|
|
298
|
+
return null;
|
|
299
|
+
return makeContext(meta);
|
|
300
|
+
}
|
|
301
|
+
/** All projects with valid metadata, most recently opened first. */
|
|
302
|
+
export function listProjects() {
|
|
303
|
+
let entries;
|
|
304
|
+
try {
|
|
305
|
+
entries = fs.readdirSync(projectsDir(), { withFileTypes: true });
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
const metas = [];
|
|
311
|
+
for (const entry of entries) {
|
|
312
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
313
|
+
continue;
|
|
314
|
+
const meta = loadMeta(entry.name);
|
|
315
|
+
if (meta !== null)
|
|
316
|
+
metas.push(meta);
|
|
317
|
+
}
|
|
318
|
+
metas.sort((a, b) => {
|
|
319
|
+
if (a.lastOpenedAt !== b.lastOpenedAt) {
|
|
320
|
+
return a.lastOpenedAt < b.lastOpenedAt ? 1 : -1;
|
|
321
|
+
}
|
|
322
|
+
return a.slug.localeCompare(b.slug);
|
|
323
|
+
});
|
|
324
|
+
return metas;
|
|
325
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps anything a provider call can throw onto the small ProviderError
|
|
3
|
+
* taxonomy {rate-limit, auth, server, network}. Provider error bodies are
|
|
4
|
+
* never echoed to the kid; describeForKid only returns T registry copy.
|
|
5
|
+
*/
|
|
6
|
+
import { APICallError, RetryError } from 'ai';
|
|
7
|
+
import { formatResetTime } from '../ui/errors.js';
|
|
8
|
+
import { T } from '../ui/text.js';
|
|
9
|
+
const NETWORK_ERROR_CODES = new Set([
|
|
10
|
+
'ECONNREFUSED',
|
|
11
|
+
'ECONNRESET',
|
|
12
|
+
'ENOTFOUND',
|
|
13
|
+
'ETIMEDOUT',
|
|
14
|
+
'EAI_AGAIN',
|
|
15
|
+
'EPIPE',
|
|
16
|
+
'ENETUNREACH',
|
|
17
|
+
'EHOSTUNREACH',
|
|
18
|
+
]);
|
|
19
|
+
function isProviderError(err) {
|
|
20
|
+
if (err === null || typeof err !== 'object') {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const kind = err.kind;
|
|
24
|
+
return kind === 'rate-limit' || kind === 'auth' || kind === 'server' || kind === 'network';
|
|
25
|
+
}
|
|
26
|
+
function isNetworkCode(code) {
|
|
27
|
+
return typeof code === 'string' && (NETWORK_ERROR_CODES.has(code) || code.startsWith('UND_ERR'));
|
|
28
|
+
}
|
|
29
|
+
function isNetworkish(err) {
|
|
30
|
+
if (err === null || typeof err !== 'object') {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
const e = err;
|
|
34
|
+
if (e.name === 'AbortError' || e.name === 'TimeoutError') {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (isNetworkCode(e.code)) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
if (e.cause !== null && typeof e.cause === 'object') {
|
|
41
|
+
if (isNetworkCode(e.cause.code)) {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (err instanceof TypeError) {
|
|
46
|
+
// Global fetch surfaces connection failures as TypeError.
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
if (typeof e.message === 'string' &&
|
|
50
|
+
/fetch failed|network|socket hang up|getaddrinfo|aborted/i.test(e.message)) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
/** Parses "90", "1m30s", "500ms" style values into whole seconds. */
|
|
56
|
+
function parseDurationSeconds(value) {
|
|
57
|
+
const v = value.trim();
|
|
58
|
+
if (/^\d+(\.\d+)?$/.test(v)) {
|
|
59
|
+
return Math.max(0, Math.round(Number(v)));
|
|
60
|
+
}
|
|
61
|
+
const re = /(\d+(?:\.\d+)?)(ms|s|m|h|d)/g;
|
|
62
|
+
let total = 0;
|
|
63
|
+
let matched = false;
|
|
64
|
+
let m;
|
|
65
|
+
while ((m = re.exec(v)) !== null) {
|
|
66
|
+
const amount = Number(m[1] ?? '0');
|
|
67
|
+
const unit = m[2] ?? 's';
|
|
68
|
+
matched = true;
|
|
69
|
+
switch (unit) {
|
|
70
|
+
case 'ms':
|
|
71
|
+
total += amount / 1000;
|
|
72
|
+
break;
|
|
73
|
+
case 's':
|
|
74
|
+
total += amount;
|
|
75
|
+
break;
|
|
76
|
+
case 'm':
|
|
77
|
+
total += amount * 60;
|
|
78
|
+
break;
|
|
79
|
+
case 'h':
|
|
80
|
+
total += amount * 3600;
|
|
81
|
+
break;
|
|
82
|
+
case 'd':
|
|
83
|
+
total += amount * 86400;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return matched ? Math.max(0, Math.round(total)) : undefined;
|
|
88
|
+
}
|
|
89
|
+
function parseRetryAfter(get) {
|
|
90
|
+
const direct = get('retry-after');
|
|
91
|
+
if (direct !== null && direct.length > 0) {
|
|
92
|
+
if (/^\d+(\.\d+)?$/.test(direct.trim())) {
|
|
93
|
+
return Math.max(0, Math.round(Number(direct)));
|
|
94
|
+
}
|
|
95
|
+
const asDate = Date.parse(direct);
|
|
96
|
+
if (!Number.isNaN(asDate)) {
|
|
97
|
+
return Math.max(0, Math.round((asDate - Date.now()) / 1000));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const backendReset = get('x-codex-primary-reset-after-seconds');
|
|
101
|
+
if (backendReset !== null && /^\d+(\.\d+)?$/.test(backendReset.trim())) {
|
|
102
|
+
return Math.max(0, Math.round(Number(backendReset)));
|
|
103
|
+
}
|
|
104
|
+
for (const name of [
|
|
105
|
+
'x-ratelimit-reset-after',
|
|
106
|
+
'x-ratelimit-reset-requests',
|
|
107
|
+
'x-ratelimit-reset-tokens',
|
|
108
|
+
]) {
|
|
109
|
+
const value = get(name);
|
|
110
|
+
if (value !== null && value.length > 0) {
|
|
111
|
+
const parsed = parseDurationSeconds(value);
|
|
112
|
+
if (parsed !== undefined) {
|
|
113
|
+
return parsed;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
function getterFromRecord(headers) {
|
|
120
|
+
if (headers === undefined) {
|
|
121
|
+
return () => null;
|
|
122
|
+
}
|
|
123
|
+
const map = new Map();
|
|
124
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
125
|
+
map.set(key.toLowerCase(), value);
|
|
126
|
+
}
|
|
127
|
+
return (name) => map.get(name.toLowerCase()) ?? null;
|
|
128
|
+
}
|
|
129
|
+
function getterFromUnknown(headers) {
|
|
130
|
+
if (headers instanceof Headers) {
|
|
131
|
+
return (name) => headers.get(name);
|
|
132
|
+
}
|
|
133
|
+
if (headers !== null && typeof headers === 'object' && !Array.isArray(headers)) {
|
|
134
|
+
const record = {};
|
|
135
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
136
|
+
if (typeof value === 'string') {
|
|
137
|
+
record[key] = value;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return getterFromRecord(record);
|
|
141
|
+
}
|
|
142
|
+
return () => null;
|
|
143
|
+
}
|
|
144
|
+
function fromStatus(status, get) {
|
|
145
|
+
if (status === 429) {
|
|
146
|
+
const retryAfter = parseRetryAfter(get);
|
|
147
|
+
return retryAfter !== undefined ? { kind: 'rate-limit', retryAfter } : { kind: 'rate-limit' };
|
|
148
|
+
}
|
|
149
|
+
if (status === 401 || status === 403) {
|
|
150
|
+
return { kind: 'auth' };
|
|
151
|
+
}
|
|
152
|
+
if (status >= 500) {
|
|
153
|
+
return { kind: 'server' };
|
|
154
|
+
}
|
|
155
|
+
return { kind: 'server' };
|
|
156
|
+
}
|
|
157
|
+
/** Classifies any thrown value from a provider call into a ProviderError. */
|
|
158
|
+
export function classifyProviderError(err) {
|
|
159
|
+
if (isProviderError(err)) {
|
|
160
|
+
return err;
|
|
161
|
+
}
|
|
162
|
+
// The AI SDK wraps the real failure in a RetryError once retries run out.
|
|
163
|
+
// Classify the last underlying error so a 429 still shows the quota screen.
|
|
164
|
+
if (RetryError.isInstance(err) && err.lastError !== undefined) {
|
|
165
|
+
return classifyProviderError(err.lastError);
|
|
166
|
+
}
|
|
167
|
+
if (APICallError.isInstance(err)) {
|
|
168
|
+
if (err.statusCode !== undefined) {
|
|
169
|
+
return fromStatus(err.statusCode, getterFromRecord(err.responseHeaders));
|
|
170
|
+
}
|
|
171
|
+
return { kind: 'network' };
|
|
172
|
+
}
|
|
173
|
+
if (err instanceof Response) {
|
|
174
|
+
return fromStatus(err.status, (name) => err.headers.get(name));
|
|
175
|
+
}
|
|
176
|
+
if (err !== null && typeof err === 'object') {
|
|
177
|
+
const o = err;
|
|
178
|
+
const status = typeof o.statusCode === 'number'
|
|
179
|
+
? o.statusCode
|
|
180
|
+
: typeof o.status === 'number'
|
|
181
|
+
? o.status
|
|
182
|
+
: undefined;
|
|
183
|
+
if (status !== undefined) {
|
|
184
|
+
return fromStatus(status, getterFromUnknown(o.responseHeaders ?? o.headers));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (isNetworkish(err)) {
|
|
188
|
+
return { kind: 'network' };
|
|
189
|
+
}
|
|
190
|
+
return { kind: 'server' };
|
|
191
|
+
}
|
|
192
|
+
/** Returns the kid copy from the T registry for this error. */
|
|
193
|
+
export function describeForKid(err) {
|
|
194
|
+
const classified = classifyProviderError(err);
|
|
195
|
+
switch (classified.kind) {
|
|
196
|
+
case 'rate-limit':
|
|
197
|
+
return classified.retryAfter !== undefined
|
|
198
|
+
? T.quota.message.replace('{time}', formatResetTime(classified.retryAfter))
|
|
199
|
+
: T.quota.messageNoTime;
|
|
200
|
+
case 'auth':
|
|
201
|
+
return T.errors.auth;
|
|
202
|
+
case 'server':
|
|
203
|
+
return T.errors.server;
|
|
204
|
+
case 'network':
|
|
205
|
+
return T.errors.network;
|
|
206
|
+
}
|
|
207
|
+
}
|