Đề xuất: (a). Cần structured outputs, Batch API và prompt caching. Qua cổng kiểu OpenAI thì mất hoặc phải tự làm lại các tính năng này.
+ Quyết định 04/11/2026: JOY chọn (b), gọi qua dos.ai ("Tao có nguyên 1 cái dos.ai cơ mà?"). Cổng có Batch API và nhận json_schema, nên job giữ được gửi theo lô, chỉ xử lý một lần và ép schema. Model đổi thành dos / qwen3.8-max vì Batch API của cổng không nhận Claude (xem mục 3).
D4. Trọng số dòng AI trong D9 (mục 9).
diff --git a/apps/importer/.env.example b/apps/importer/.env.example
index 1c33ceca..01927701 100644
--- a/apps/importer/.env.example
+++ b/apps/importer/.env.example
@@ -19,8 +19,13 @@ AI_BASE_URL=
AI_MODEL=
AI_TIMEOUT_MS=25000
-# Provider-specific keys
+# Provider-specific keys. The review AI points job (jobs/review-ai-points.ts) uses only
+# DOS_AI_API_KEY (a dos_sk_ key) and DOS_AI_BASE_URL (default https://api.dos.me/v1),
+# with REVIEW_AI_POINTS_ENABLED=true and REVIEW_AI_MODEL (dos or qwen3.8-max).
DOS_AI_API_KEY=
+DOS_AI_BASE_URL=
+REVIEW_AI_POINTS_ENABLED=
+REVIEW_AI_MODEL=
OPENAI_API_KEY=
GEMINI_API_KEY=
DEEPSEEK_API_KEY=
diff --git a/apps/importer/package.json b/apps/importer/package.json
index fe8fa907..143cd899 100644
--- a/apps/importer/package.json
+++ b/apps/importer/package.json
@@ -58,7 +58,6 @@
"node": ">=22.0.0"
},
"dependencies": {
- "@anthropic-ai/sdk": "1.3.0",
"@repo/shared-data": "workspace:*",
"@supabase/supabase-js": "^2.117.2",
"dotenv": "^18.0.8"
diff --git a/apps/importer/src/shopee/__tests__/review-ai-points.test.ts b/apps/importer/src/shopee/__tests__/review-ai-points.test.ts
index 04c896aa..8669eb43 100644
--- a/apps/importer/src/shopee/__tests__/review-ai-points.test.ts
+++ b/apps/importer/src/shopee/__tests__/review-ai-points.test.ts
@@ -16,7 +16,10 @@ import {
import {
aiCandidates,
buildRequests,
+ BatchGoneError,
collectBatch,
+ dosAiReviewAiClient,
+ fromDosAiOutputLine,
qualityStop,
runReviewAiPointsJob,
type AiMarkerRow,
@@ -169,24 +172,25 @@ function testPrompt() {
too_many: 1,
});
- // Prices: Haiku 5.5 at batch prices, cache reads and writes included.
+ // Prices: dos.ai list prices at the Batch API's half, cached tokens at the full input
+ // price (an upper bound).
assert.equal(
- batchCostUsd('claude-haiku-5-5', {
+ batchCostUsd('dos', {
input_tokens: 1_000_000,
output_tokens: 1_000_000,
cache_read_input_tokens: 1_000_000,
cache_creation_input_tokens: 1_000_000,
}).toFixed(4),
- ((0.1 + 0.5 + 0.01 + 0.125) * 0.5).toFixed(4)
+ ((0.25 + 1.5 + 0.25 + 0.25) * 0.5).toFixed(4)
);
assert.equal(
- batchCostUsd('claude-sonnet-5-5', { output_tokens: 1_000_000 }),
- 20 * batchCostUsd('claude-haiku-5-5', { output_tokens: 1_000_000 })
+ batchCostUsd('qwen3.8-max', { output_tokens: 1_000_000 }),
+ 4 * batchCostUsd('dos', { output_tokens: 1_000_000 })
);
- const ceiling = reserveUsd('claude-haiku-5-5', [
+ const ceiling = reserveUsd('dos', [
{ instructions: 'x'.repeat(3000), message: 'y'.repeat(8000) },
]);
- assert.ok(ceiling > 0.0015 && ceiling < 0.003, `one request's ceiling ${ceiling}`);
+ assert.ok(ceiling > 0.004 && ceiling < 0.007, `one request's ceiling ${ceiling}`);
console.log(' ✅ hash, qualifying text, schema, evidence, line checks and prices verified');
}
@@ -252,16 +256,23 @@ function testCandidates() {
textHash: hash,
category: i < 41 ? 'smartphones' : 'laptops',
}));
- const built = buildRequests(many, 'claude-haiku-5-5');
+ const built = buildRequests(many, 'dos');
assert.deepEqual(
built.manifest.map(r => `${r.custom_id}:${r.reviews.length}`),
['laptops-0:4', 'smartphones-1:20', 'smartphones-2:20', 'smartphones-3:1']
);
+ // One line of an OpenAI-format batch file, as dos.ai's Batch API takes it.
const request = built.requests[1]!;
- assert.equal(request.params.model, 'claude-haiku-5-5');
- assert.equal(request.params.output_config.effort, 'low');
- assert.equal(request.params.system[0]!.cache_control.type, 'ephemeral');
- const sent = JSON.parse(request.params.messages[0]!.content.split('\n').slice(1).join('\n')) as {
+ assert.deepEqual([request.method, request.url], ['POST', '/v1/chat/completions']);
+ assert.equal(request.body.model, 'dos');
+ assert.equal(request.body.stream, false);
+ assert.equal(request.body.messages[0].role, 'system');
+ assert.equal(request.body.messages[0].content, reviewAiInstructions('smartphones'));
+ assert.deepEqual(
+ [request.body.response_format.type, request.body.response_format.json_schema.strict],
+ ['json_schema', true]
+ );
+ const sent = JSON.parse(request.body.messages[1].content.split('\n').slice(1).join('\n')) as {
ref: number;
text: string;
}[];
@@ -269,7 +280,7 @@ function testCandidates() {
sent.map(r => r.ref),
Array.from({ length: 20 }, (_, i) => i + 1)
);
- assert.ok(!request.params.messages[0]!.content.includes(id(100)), 'no review id is sent');
+ assert.ok(!JSON.stringify(request).includes(id(100)), 'no review id is sent');
console.log(' ✅ the gate, the process-once rule and the requests verified');
}
@@ -351,7 +362,7 @@ function testCollect() {
];
const texts = new Map([1, 2, 3, 4, 5, 6, 7].map(n => [id(n), LONG]));
const collected = collectBatch(
- { id: 'msgbatch_test0001', model: 'claude-haiku-5-5', prompt_version: 'v1', manifest },
+ { id: 'batch_test0001', model: 'dos', prompt_version: 'v1', manifest },
results,
texts
);
@@ -366,16 +377,16 @@ function testCollect() {
for (const n of [3, 4, 5, 6, 7])
assert.equal(rows.get(id(n))!.failed, true, `review ${n} failed`);
assert.equal(collected.failedReviews, 5);
- assert.equal(rows.get(id(1))!.batch_id, 'msgbatch_test0001');
+ assert.equal(rows.get(id(1))!.batch_id, 'batch_test0001');
assert.equal(collected.usage.input_tokens, 16000, 'cut-off and refused requests are paid for');
assert.equal(
collected.costUsd,
- batchCostUsd('claude-haiku-5-5', { input_tokens: 16000, output_tokens: 3600 })
+ batchCostUsd('dos', { input_tokens: 16000, output_tokens: 3600 })
);
const errored = collectBatch(
{
- id: 'msgbatch_test0002',
- model: 'claude-haiku-5-5',
+ id: 'batch_test0002',
+ model: 'dos',
prompt_version: 'v1',
manifest: manifest.slice(1, 2),
},
@@ -476,7 +487,7 @@ async function testJob() {
assert.equal(batch.status, 'stored');
assert.equal(
Number(batch.cost_usd),
- Number(batchCostUsd('claude-haiku-5-5', { input_tokens: 4000, output_tokens: 900 }).toFixed(4))
+ Number(batchCostUsd('dos', { input_tokens: 4000, output_tokens: 900 }).toFixed(4))
);
assert.equal(db.stores.flat().length, 3);
assert.ok(
@@ -499,8 +510,8 @@ async function testJob() {
// A crash after the batch was created: the next run collects it without sending again.
const crashed = fakeSupabase(seed());
crashed.tables.review_ai_batches!.push({
- id: 'msgbatch_crashed01',
- model: 'claude-haiku-5-5',
+ id: 'batch_crashed01',
+ model: 'dos',
prompt_version: REVIEW_AI_PROMPT_VERSION,
status: 'submitted',
manifest: [
@@ -517,7 +528,7 @@ async function testJob() {
stored_at: null,
});
ai.calls.length = 0;
- ai.batches.set('msgbatch_crashed01', [
+ ai.batches.set('batch_crashed01', [
{ custom_id: 'smartphones-0', ...answer([1, 2, 3].map(ref => ({ ref, points: GOOD_POINTS }))) },
]);
// A dry run while it is open does not count its reviews as due.
@@ -532,7 +543,7 @@ async function testJob() {
category_slug: 'smartphones',
outcome: 'stored',
attempts: 1,
- batch_id: 'msgbatch_crashed01',
+ batch_id: 'batch_crashed01',
});
const resumed = await runReviewAiPointsJob(crashed.client, {
apply: true,
@@ -540,7 +551,7 @@ async function testJob() {
ai: ai.client,
now,
});
- assert.deepEqual(resumed.collected, ['msgbatch_crashed01']);
+ assert.deepEqual(resumed.collected, ['batch_crashed01']);
assert.equal(resumed.sent, 0, 'nothing sent twice');
assert.ok(!ai.calls.includes('create'));
assert.deepEqual(
@@ -553,7 +564,7 @@ async function testJob() {
const running = fakeSupabase(seed());
running.tables.review_ai_batches!.push({
...crashed.tables.review_ai_batches![0]!,
- id: 'msgbatch_running01',
+ id: 'batch_running01',
status: 'submitted',
cost_usd: null,
});
@@ -567,7 +578,7 @@ async function testJob() {
waitMs: 0,
wait: async () => {},
});
- assert.deepEqual(waited.waiting, ['msgbatch_running01']);
+ assert.deepEqual(waited.waiting, ['batch_running01']);
assert.equal(waited.candidates, 0);
assert.ok(!ai.calls.includes('create'));
// ...and the kill switch cancels it.
@@ -577,7 +588,7 @@ async function testJob() {
ai: ai.client,
now,
});
- assert.deepEqual(stopped.canceled, ['msgbatch_running01']);
+ assert.deepEqual(stopped.canceled, ['batch_running01']);
assert.ok(ai.calls.includes('cancel'));
assert.equal(
running.tables.review_ai_batches![0]!.status,
@@ -587,9 +598,7 @@ async function testJob() {
// Switched on again: the canceled batch ends; its finished request is stored, the
// canceled one comes back as failed rows, and nothing is sent twice.
ai.status = 'ended';
- ai.batches.set('msgbatch_running01', [
- { custom_id: 'smartphones-0', result: { type: 'canceled' } },
- ]);
+ ai.batches.set('batch_running01', [{ custom_id: 'smartphones-0', result: { type: 'canceled' } }]);
ai.calls.length = 0;
const back = await runReviewAiPointsJob(running.client, {
apply: true,
@@ -597,7 +606,7 @@ async function testJob() {
ai: ai.client,
now,
});
- assert.equal(back.collected[0], 'msgbatch_running01', 'the old batch first, then the new one');
+ assert.equal(back.collected[0], 'batch_running01', 'the old batch first, then the new one');
assert.equal(back.stored.failed, 3);
// The fake writer has no attempts; the failed reviews are due again, in a new batch.
assert.equal(back.sent, 3);
@@ -605,8 +614,8 @@ async function testJob() {
// The monthly cap: this month's spend plus the ceiling over $5 sends nothing.
const capped = fakeSupabase(seed());
capped.tables.review_ai_batches!.push({
- id: 'msgbatch_spent0001',
- model: 'claude-haiku-5-5',
+ id: 'batch_spent0001',
+ model: 'dos',
prompt_version: 'v0',
status: 'stored',
manifest: [],
@@ -616,8 +625,8 @@ async function testJob() {
stored_at: '2026-11-02T01:00:00Z',
});
capped.tables.review_ai_batches!.push({
- id: 'msgbatch_lastmonth',
- model: 'claude-haiku-5-5',
+ id: 'batch_lastmonth',
+ model: 'dos',
prompt_version: 'v0',
status: 'stored',
manifest: [],
@@ -636,12 +645,12 @@ async function testJob() {
assert.equal(over.capped, true);
assert.equal(over.monthSpendUsd, 4.999, 'last month does not count');
assert.equal(ai.calls.length, 0);
- const sonnet = await runReviewAiPointsJob(capped.client, {
+ const qwenMax = await runReviewAiPointsJob(capped.client, {
enabled: true,
- model: 'claude-sonnet-5-5',
+ model: 'qwen3.8-max',
now,
});
- assert.equal(sonnet.capped, false, 'each model has its own cap');
+ assert.equal(qwenMax.capped, false, 'each model has its own cap');
// A quality stop: most quotes made up; nothing stored, the batch left ended.
const bad = fakeSupabase(seed());
@@ -679,6 +688,37 @@ async function testJob() {
/--wait-minutes/
);
+ // A batch the gateway failed as a whole: the row is closed, nothing is stored, and the
+ // reviews are due again with no attempt spent.
+ const goneDb = fakeSupabase(seed());
+ goneDb.tables.review_ai_batches!.push({
+ ...crashed.tables.review_ai_batches![0]!,
+ id: 'batch_failed01',
+ status: 'submitted',
+ cost_usd: null,
+ stored_at: null,
+ });
+ const goneAi: ReviewAiClient = {
+ ...ai.client,
+ async createBatch() {
+ throw new Error('not in this test');
+ },
+ // eslint-disable-next-line require-yield
+ async *results() {
+ throw new BatchGoneError('dos.ai batch ended failed with no output', 'canceled');
+ },
+ };
+ const afterGone = await runReviewAiPointsJob(goneDb.client, {
+ enabled: true,
+ ai: goneAi,
+ now,
+ apply: true,
+ maxReviews: 1,
+ }).catch((err: unknown) => err);
+ assert.match(String(afterGone), /not in this test/, 'its reviews were due again and sent');
+ assert.equal(goneDb.tables.review_ai_batches![0]!.status, 'canceled');
+ assert.equal(goneDb.stores.length, 0);
+
// The purge: a dry run says so; --apply calls the writer; a bad version is refused.
const purge = fakeSupabase(seed());
const purgeDry = await runReviewAiPointsJob(purge.client, { purge: 'v1' });
@@ -697,8 +737,187 @@ async function testJob() {
);
}
+async function testDosAiClient() {
+ // Output lines: a normal answer, a cut-off one, a filtered one, an error, a 400.
+ const ok = (finish: string) => ({
+ status_code: 200,
+ body: {
+ choices: [{ message: { content: '{"reviews":[]}' }, finish_reason: finish }],
+ usage: {
+ prompt_tokens: 1000,
+ completion_tokens: 50,
+ prompt_tokens_details: { cached_tokens: 600 },
+ },
+ },
+ });
+ const read = (line: object) => fromDosAiOutputLine(line);
+ const stopped = read({ custom_id: 'a', response: ok('stop') });
+ assert.equal(stopped?.result.type, 'succeeded');
+ if (stopped?.result.type === 'succeeded') {
+ assert.equal(stopped.result.message.stop_reason, 'end_turn');
+ assert.deepEqual(stopped.result.message.usage, {
+ input_tokens: 400,
+ output_tokens: 50,
+ cache_read_input_tokens: 600,
+ cache_creation_input_tokens: 0,
+ });
+ }
+ const reason = (line: object) => {
+ const r = read(line);
+ return r?.result.type === 'succeeded' ? r.result.message.stop_reason : r?.result.type;
+ };
+ assert.equal(reason({ custom_id: 'b', response: ok('length') }), 'max_tokens');
+ assert.equal(reason({ custom_id: 'c', response: ok('content_filter') }), 'refusal');
+ assert.equal(
+ reason({ custom_id: 'd', error: { code: 'model_sunset', message: 'x' } }),
+ 'errored'
+ );
+ assert.equal(reason({ custom_id: 'e', response: { status_code: 400, body: {} } }), 'errored');
+ assert.equal(read({ response: ok('stop') }), null, 'a line with no custom_id is skipped');
+
+ // The client against a fake gateway: upload, create, status, results, cancel, auth, retry, 402.
+ const calls: string[] = [];
+ let busy = 1;
+ const gateway = (async (url: string | URL | Request, init?: RequestInit) => {
+ const path = String(url).replace('https://beta-api.dos.ai/v1', '');
+ const method = init?.method ?? 'GET';
+ calls.push(`${method} ${path}`);
+ assert.equal(
+ (init?.headers as Record
).Authorization,
+ 'Bearer dos_sk_test',
+ 'the key as a Bearer token'
+ );
+ const reply = (status: number, body: unknown) =>
+ new Response(typeof body === 'string' ? body : JSON.stringify(body), { status });
+ if (path === '/files' && method === 'POST') {
+ const form = init!.body as FormData;
+ assert.equal(form.get('purpose'), 'batch');
+ const lines = (await (form.get('file') as Blob).text()).trim().split('\n');
+ assert.equal(JSON.parse(lines[0]!).url, '/v1/chat/completions');
+ return reply(200, { id: 'file_in' });
+ }
+ if (path === '/batches' && method === 'POST') {
+ const body = JSON.parse(String(init!.body)) as Record;
+ assert.deepEqual(body, {
+ input_file_id: 'file_in',
+ endpoint: '/v1/chat/completions',
+ completion_window: '24h',
+ });
+ return reply(200, { id: 'batch_abc123' });
+ }
+ if (path === '/batches/batch_abc123') {
+ if (busy-- > 0) return reply(503, 'busy');
+ return reply(200, { status: 'completed', output_file_id: 'file_out' });
+ }
+ if (path === '/files/file_out/content') {
+ return reply(
+ 200,
+ [
+ JSON.stringify({ custom_id: 'smartphones-0', response: ok('stop') }),
+ 'not json',
+ JSON.stringify({ custom_id: 'smartphones-1', error: { message: 'x' } }),
+ ].join('\n')
+ );
+ }
+ if (path === '/batches/batch_abc123/cancel') return reply(200, { status: 'cancelling' });
+ if (path === '/batches/batch_broke') return reply(402, { error: 'insufficient credits' });
+ if (path === '/batches/batch_failed') {
+ return reply(200, { status: 'failed', output_file_id: null, errmsg: 'zdr org' });
+ }
+ if (path === '/batches/batch_purged') {
+ return reply(200, { status: 'completed', output_file_id: 'file_gone' });
+ }
+ return reply(404, 'no route');
+ }) as typeof fetch;
+ const noWait = async () => {};
+ const client = dosAiReviewAiClient('dos_sk_test', 'https://beta-api.dos.ai/v1/', gateway, noWait);
+ const built = buildRequests(
+ [{ id: id(1), text: LONG, textHash: reviewTextHash(LONG), category: 'smartphones' }],
+ 'dos'
+ );
+ assert.deepEqual(await client.createBatch(built.requests), { id: 'batch_abc123' });
+ assert.equal(await client.batchStatus('batch_abc123'), 'ended', 'a 503 is retried');
+ const out: BatchResult[] = [];
+ for await (const r of client.results('batch_abc123')) out.push(r);
+ assert.deepEqual(
+ out.map(r => `${r.custom_id}:${r.result.type}`),
+ ['smartphones-0:succeeded', 'smartphones-1:errored']
+ );
+ await client.cancelBatch('batch_abc123');
+ await assert.rejects(client.batchStatus('batch_broke'), /no credits left/);
+ await assert.rejects(client.batchStatus('batch_missing'), /404/);
+ assert.deepEqual(calls.slice(0, 3), [
+ 'POST /files',
+ 'POST /batches',
+ 'GET /batches/batch_abc123',
+ ]);
+ // A batch failed as a whole, or one whose output file is gone: no results, ever.
+ const drain = async (batchId: string) => {
+ for await (const r of client.results(batchId)) void r;
+ };
+ await assert.rejects(drain('batch_failed'), (err: unknown) => {
+ assert.ok(err instanceof BatchGoneError);
+ assert.equal(err.status, 'canceled');
+ assert.match(err.message, /ended failed with no output: zdr org/);
+ return true;
+ });
+ await assert.rejects(drain('batch_purged'), (err: unknown) => {
+ assert.ok(err instanceof BatchGoneError);
+ assert.equal(err.status, 'expired');
+ return true;
+ });
+ // A line the gateway did not run for want of credits is unbilled, not failed.
+ assert.equal(reason({ custom_id: 'f', response: { status_code: 402, body: {} } }), 'unbilled');
+ assert.equal(
+ reason({ custom_id: 'g', error: { code: 'insufficient_credits', message: 'x' } }),
+ 'unbilled'
+ );
+
+ // POST /batches is never retried on an answer that may hide a created batch.
+ for (const lost of ['network', '502'] as const) {
+ let creates = 0;
+ const flaky = (async (url: string | URL | Request, init?: RequestInit) => {
+ const path = String(url).replace('https://api.dos.me/v1', '');
+ if (path === '/files') return new Response(JSON.stringify({ id: 'file_in' }));
+ creates += 1;
+ if (init?.method === 'POST' && lost === 'network') throw new Error('socket hang up');
+ return new Response('bad gateway', { status: 502 });
+ }) as typeof fetch;
+ const once = dosAiReviewAiClient('dos_sk_test', undefined, flaky, noWait);
+ await assert.rejects(
+ once.createBatch(built.requests),
+ lost === 'network' ? /A batch may have been created/ : /502/
+ );
+ assert.equal(creates, 1, `POST /batches sent once (${lost})`);
+ }
+ // ...but a 429, refused before anything exists, is retried.
+ let throttled = 0;
+ const busyGateway = (async (url: string | URL | Request) => {
+ const path = String(url).replace('https://api.dos.me/v1', '');
+ if (path === '/files') return new Response(JSON.stringify({ id: 'file_in' }));
+ throttled += 1;
+ return throttled === 1
+ ? new Response('slow down', { status: 429 })
+ : new Response(JSON.stringify({ id: 'batch_after_429' }));
+ }) as typeof fetch;
+ assert.deepEqual(
+ await dosAiReviewAiClient('dos_sk_test', undefined, busyGateway, noWait).createBatch(
+ built.requests
+ ),
+ { id: 'batch_after_429' }
+ );
+ // The request body: deterministic and bounded.
+ assert.deepEqual(
+ [built.requests[0]!.body.temperature, built.requests[0]!.body.max_tokens],
+ [0, 6000]
+ );
+ console.log(
+ ' ✅ dos.ai batch client: upload, create once, status, results, gone batches, cancel, retry and 402'
+ );
+}
+
function refsOf(request: BatchRequest): number[] {
- const content = request.params.messages[0]!.content;
+ const content = request.body.messages[1].content;
return (JSON.parse(content.slice(content.indexOf('\n') + 1)) as { ref: number }[]).map(
r => r.ref
);
@@ -717,7 +936,7 @@ function fakeAi(answerFor: (requests: BatchRequest[]) => BatchResult[]) {
async createBatch(requests) {
state.calls.push('create');
state.onCreate?.();
- const batchId = `msgbatch_fake${String((n += 1)).padStart(4, '0')}`;
+ const batchId = `batch_fake${String((n += 1)).padStart(4, '0')}`;
state.batches.set(batchId, answerFor(requests));
return { id: batchId };
},
@@ -855,6 +1074,7 @@ async function run() {
testCandidates();
testCollect();
await testJob();
+ await testDosAiClient();
}
run().catch(err => {
diff --git a/apps/importer/src/shopee/jobs/review-ai-points.ts b/apps/importer/src/shopee/jobs/review-ai-points.ts
index a0467792..a7eaf57d 100644
--- a/apps/importer/src/shopee/jobs/review-ai-points.ts
+++ b/apps/importer/src/shopee/jobs/review-ai-points.ts
@@ -6,15 +6,17 @@
* [--apply] [--purge ] [--max ] [--wait-minutes ]
*
* Reads crawled user reviews, picks those that qualify (design section 1, D3),
- * sends them through the Message Batches API with a structured output schema
- * per category (section 2), checks every line it gets back (section 7.2) and
+ * sends them through the dos.ai gateway's Batch API (OpenAI format, D2 as JOY
+ * decided on 2026-11-04) with a structured output schema per category
+ * (section 2), checks every line it gets back (section 7.2) and
* stores what passes as rate.review_points method 'ai' through
* rate.store_review_ai_points, which writes the lines and the process-once
* marker (rate.review_ai_extractions) in one transaction.
*
* Dry run by default: it reads, plans and logs counts and the cost ceiling,
- * and never calls a model (no API key needed). `--apply` sends one batch,
- * waits for it (at most --wait-minutes, default 60) and stores the results.
+ * and never calls a model (no key needed). `--apply` sends one batch with the
+ * key in DOS_AI_API_KEY (DOS_AI_BASE_URL overrides the gateway URL), waits for
+ * it (at most --wait-minutes, default 60) and stores the results.
* It signs in as the rate_worker user (RATE_WORKER_EMAIL and
* RATE_WORKER_PASSWORD with the publishable key), which reads the review
* tables, writes the batch record and writes lines only through the writer.
@@ -22,9 +24,10 @@
* Switches and limits (section 8, D5):
* - REVIEW_AI_POINTS_ENABLED must be 'true', or the job exits before any
* model call; with --apply it also cancels a batch still running.
- * - REVIEW_AI_MODEL: claude-haiku-5-5 (default) or claude-sonnet-5-5 (D1).
+ * - REVIEW_AI_MODEL: dos (default, Qwen3.8 27B on DOS's GPUs) or
+ * qwen3.8-max, the models the gateway's Batch API serves (D1's shape).
* - At most 2000 reviews a run (--max lowers it).
- * - A monthly cap per model ($5 Haiku, $15 Sonnet). The month's spend is the
+ * - A monthly cap per model ($5 dos, $15 qwen3.8-max). The month's spend is the
* reported cost of its batches, or their reserved ceiling while running,
* read from rate.review_ai_batches. A batch whose ceiling would pass the
* cap is not sent, and the CLI exits with code 3.
@@ -97,22 +100,26 @@ export const SHARE_JUMP_STOP = 0.3;
/** ...when both batches have at least this many reviews. */
export const SHARE_JUMP_MIN_REVIEWS = 50;
const DEADLOCK_ATTEMPTS = 4;
-/** The Batch API keeps results 29 days; past 28 a batch is treated as gone. */
+/** A batch whose results cannot be read after 28 days is treated as gone. */
const RESULTS_KEPT_MS = 28 * 24 * 60 * 60 * 1000;
const PROMPT_VERSION_RE = /^[a-z0-9][a-z0-9._-]{0,31}$/;
// --- The model client: the Batch API as the job uses it, so tests pass a fake. ------------
+/** One line of a dos.ai (OpenAI-format) batch input file. */
export interface BatchRequest {
custom_id: string;
- params: {
+ method: 'POST';
+ url: '/v1/chat/completions';
+ body: {
model: ReviewAiModel;
max_tokens: number;
- system: { type: 'text'; text: string; cache_control: { type: 'ephemeral' } }[];
- messages: { role: 'user'; content: string }[];
- output_config: {
- effort: 'low';
- format: { type: 'json_schema'; schema: Record };
+ temperature: number;
+ stream: false;
+ messages: [{ role: 'system'; content: string }, { role: 'user'; content: string }];
+ response_format: {
+ type: 'json_schema';
+ json_schema: { name: string; strict: true; schema: Record };
};
};
}
@@ -136,7 +143,7 @@ export type BatchResult =
};
};
}
- | { custom_id: string; result: { type: 'errored' | 'expired' | 'canceled' } };
+ | { custom_id: string; result: { type: 'errored' | 'expired' | 'canceled' | 'unbilled' } };
export interface ReviewAiClient {
createBatch(requests: BatchRequest[]): Promise<{ id: string }>;
@@ -145,29 +152,226 @@ export interface ReviewAiClient {
cancelBatch(id: string): Promise;
}
-/** The real client: the official Anthropic SDK, the key from ANTHROPIC_API_KEY. */
-export async function anthropicReviewAiClient(apiKey: string): Promise {
- const { default: Anthropic } = await import('@anthropic-ai/sdk');
- const client = new Anthropic({ apiKey });
+/** The dos.ai gateway's default base URL, as the importer's other AI clients use it. */
+export const DOS_AI_DEFAULT_BASE_URL = 'https://api.dos.me/v1';
+const DOS_AI_TIMEOUT_MS = 60_000;
+const DOS_AI_ATTEMPTS = 3;
+
+/** One line of a dos.ai batch output file (OpenAI batch format). */
+interface DosAiOutputLine {
+ custom_id?: string;
+ response?: {
+ status_code?: number;
+ body?: {
+ choices?: { message?: { content?: string | null }; finish_reason?: string | null }[];
+ usage?: {
+ prompt_tokens?: number;
+ completion_tokens?: number;
+ prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number } | null;
+ };
+ };
+ } | null;
+ error?: { code?: string; message?: string } | null;
+}
+
+/**
+ * A dos.ai output line as the job reads results: a 200 response is a
+ * message whose stop reason is the finish reason ('stop' ends normally,
+ * 'length' is cut off, 'content_filter' is refused); a line the gateway did
+ * not run for want of credits (402, insufficient_credits) is 'unbilled', so
+ * the review stays due without spending an attempt; anything else errored.
+ * Cached prompt tokens are split out of prompt_tokens.
+ */
+export function fromDosAiOutputLine(line: DosAiOutputLine): BatchResult | null {
+ if (typeof line.custom_id !== 'string') return null;
+ const body = line.response?.body;
+ if (
+ line.response?.status_code === 402 ||
+ /insufficient|credit|balance/i.test(line.error?.code ?? '')
+ ) {
+ return { custom_id: line.custom_id, result: { type: 'unbilled' } };
+ }
+ if (line.error || line.response?.status_code !== 200 || !body) {
+ return { custom_id: line.custom_id, result: { type: 'errored' } };
+ }
+ const choice = body.choices?.[0];
+ const finish = choice?.finish_reason ?? null;
+ const n = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
+ const cachedRead = n(body.usage?.prompt_tokens_details?.cached_tokens);
+ const cachedWrite = n(body.usage?.prompt_tokens_details?.cache_write_tokens);
+ return {
+ custom_id: line.custom_id,
+ result: {
+ type: 'succeeded',
+ message: {
+ stop_reason:
+ finish === 'stop'
+ ? 'end_turn'
+ : finish === 'length'
+ ? 'max_tokens'
+ : finish === 'content_filter'
+ ? 'refusal'
+ : finish,
+ content: [{ type: 'text', text: choice?.message?.content ?? '' }],
+ usage: {
+ input_tokens: Math.max(0, n(body.usage?.prompt_tokens) - cachedRead - cachedWrite),
+ output_tokens: n(body.usage?.completion_tokens),
+ cache_read_input_tokens: cachedRead,
+ cache_creation_input_tokens: cachedWrite,
+ },
+ },
+ },
+ };
+}
+
+/**
+ * A batch whose results will never come: the gateway failed it as a whole
+ * (a refused file, a Zero Data Retention organization, no credits at start),
+ * it ended with no output, or its output file is gone. The job closes its row
+ * and stores nothing, so its reviews are due again without spending an attempt.
+ */
+export class BatchGoneError extends Error {
+ constructor(
+ message: string,
+ readonly status: 'canceled' | 'expired'
+ ) {
+ super(message);
+ this.name = 'BatchGoneError';
+ }
+}
+
+/**
+ * The real client: the dos.ai gateway's OpenAI-compatible Files and Batch API
+ * (POST /files, POST /batches, GET /batches/:id, POST /batches/:id/cancel,
+ * GET /files/:id/content), with the key in DOS_AI_API_KEY as a Bearer token.
+ * Each call, body included, has a timeout. A 429 or 5xx, or a network error,
+ * is retried, except on POST /batches: a batch may have been created behind a
+ * lost answer, and a retry would run and bill a second one, so that call is
+ * retried only on 429 (refused before anything was created). A 402 (no
+ * credits left) stops the run.
+ */
+export function dosAiReviewAiClient(
+ apiKey: string,
+ baseUrl: string = DOS_AI_DEFAULT_BASE_URL,
+ fetchImpl: typeof fetch = fetch,
+ wait: (ms: number) => Promise = ms => new Promise(r => setTimeout(r, ms))
+): ReviewAiClient {
+ const base = baseUrl.replace(/\/+$/, '');
+ const call = async (
+ path: string,
+ init: RequestInit = {},
+ retry: 'all' | 'throttled' = 'all'
+ ): Promise<{ status: number; text: string }> => {
+ for (let attempt = 1; ; attempt++) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), DOS_AI_TIMEOUT_MS);
+ let status = 0;
+ let text = '';
+ let failure: unknown = null;
+ try {
+ const response = await fetchImpl(`${base}${path}`, {
+ ...init,
+ headers: { Authorization: `Bearer ${apiKey}`, ...(init.headers ?? {}) },
+ signal: controller.signal,
+ });
+ status = response.status;
+ text = await response.text();
+ } catch (err) {
+ failure = err;
+ } finally {
+ clearTimeout(timer);
+ }
+ if (failure === null && status >= 200 && status < 300) return { status, text };
+ if (status === 402) {
+ throw new Error('dos.ai refused the request: no credits left on the key (402).');
+ }
+ const retryable =
+ retry === 'all' ? failure !== null || status === 429 || status >= 500 : status === 429;
+ if (!retryable || attempt >= DOS_AI_ATTEMPTS) {
+ if (failure !== null && retry === 'throttled') {
+ throw new Error(
+ `dos.ai ${init.method ?? 'GET'} ${path}: no answer (${String(failure)}). A batch may have been created; check the key's batches on dos.ai before the next run.`
+ );
+ }
+ if (failure !== null) throw failure;
+ // The body names the reason; it holds no review text of ours.
+ throw new Error(`dos.ai ${init.method ?? 'GET'} ${path}: ${status} ${text.slice(0, 300)}`);
+ }
+ await wait(1000 * 2 ** (attempt - 1));
+ }
+ };
+ const json = async (path: string, init?: RequestInit, retry?: 'all' | 'throttled') =>
+ JSON.parse((await call(path, init, retry)).text) as T;
return {
async createBatch(requests) {
- const batch = await client.messages.batches.create({
- requests: requests as unknown as Parameters<
- typeof client.messages.batches.create
- >[0]['requests'],
- });
+ const form = new FormData();
+ form.append('purpose', 'batch');
+ form.append(
+ 'file',
+ new Blob([requests.map(r => JSON.stringify(r)).join('\n') + '\n'], {
+ type: 'application/jsonl',
+ }),
+ 'review-ai-points.jsonl'
+ );
+ const file = await json<{ id: string }>('/files', { method: 'POST', body: form });
+ const batch = await json<{ id: string }>(
+ '/batches',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ input_file_id: file.id,
+ endpoint: '/v1/chat/completions',
+ completion_window: '24h',
+ }),
+ },
+ 'throttled'
+ );
return { id: batch.id };
},
async batchStatus(id) {
- return (await client.messages.batches.retrieve(id)).processing_status;
+ const batch = await json<{ status: string }>(`/batches/${encodeURIComponent(id)}`);
+ if (batch.status === 'cancelling') return 'canceling';
+ return ['completed', 'failed', 'cancelled', 'expired'].includes(batch.status)
+ ? 'ended'
+ : 'in_progress';
},
async *results(id) {
- for await (const entry of await client.messages.batches.results(id)) {
- yield entry as unknown as BatchResult;
+ const batch = await json<{
+ status?: string;
+ output_file_id?: string | null;
+ errmsg?: string | null;
+ }>(`/batches/${encodeURIComponent(id)}`);
+ if (!batch.output_file_id) {
+ // Failed as a whole, or ended with no line run: nothing to store.
+ throw new BatchGoneError(
+ `dos.ai batch ${id} ended ${batch.status ?? 'without a status'} with no output${batch.errmsg ? `: ${batch.errmsg.slice(0, 200)}` : ''}`,
+ batch.status === 'expired' ? 'expired' : 'canceled'
+ );
+ }
+ let text: string;
+ try {
+ text = (await call(`/files/${encodeURIComponent(batch.output_file_id)}/content`)).text;
+ } catch (err) {
+ if (/ 404 /.test(String(err))) {
+ throw new BatchGoneError(`dos.ai batch ${id}: its output file is gone (404)`, 'expired');
+ }
+ throw err;
+ }
+ for (const raw of text.split('\n')) {
+ if (!raw.trim()) continue;
+ let line: DosAiOutputLine;
+ try {
+ line = JSON.parse(raw) as DosAiOutputLine;
+ } catch {
+ continue;
+ }
+ const result = fromDosAiOutputLine(line);
+ if (result) yield result;
}
},
async cancelBatch(id) {
- await client.messages.batches.cancel(id);
+ await call(`/batches/${encodeURIComponent(id)}/cancel`, { method: 'POST' });
},
};
}
@@ -384,17 +588,26 @@ export function buildRequests(
const refs = part.map((c, j) => ({ ref: j + 1, candidate: c }));
requests.push({
custom_id,
- params: {
+ method: 'POST',
+ url: '/v1/chat/completions',
+ body: {
model,
max_tokens: REQUEST_MAX_TOKENS,
- system: [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }],
+ temperature: 0,
+ stream: false,
+ // The instructions first and the same for a category: a shared prefix the
+ // gateway's backends can cache on their own.
messages: [
+ { role: 'system', content: system },
{
role: 'user',
content: reviewAiUserMessage(refs.map(r => ({ ref: r.ref, text: r.candidate.text }))),
},
],
- output_config: { effort: 'low', format: { type: 'json_schema', schema } },
+ response_format: {
+ type: 'json_schema',
+ json_schema: { name: 'review_points', strict: true, schema },
+ },
},
});
instructions.push(system);
@@ -423,6 +636,8 @@ export interface CollectedBatch {
rejects: Record;
unaccented: number;
failedReviews: number;
+ /** Reviews in requests the gateway did not run for want of credits: still due. */
+ unbilledReviews: number;
reviewsWithLines: number;
reviewsAnswered: number;
}
@@ -475,6 +690,7 @@ export function collectBatch(
},
unaccented: 0,
failedReviews: 0,
+ unbilledReviews: 0,
reviewsWithLines: 0,
reviewsAnswered: 0,
};
@@ -499,6 +715,11 @@ export function collectBatch(
}
}
}
+ // A request the gateway never ran for want of credits: its reviews stay due, no attempt spent.
+ if (result?.result.type === 'unbilled') {
+ out.unbilledReviews += request.reviews.length;
+ continue;
+ }
for (const review of request.reviews) {
const base = {
id: review.id,
@@ -673,7 +894,7 @@ export async function runReviewAiPointsJob(
purged: null,
};
const needAi = (): ReviewAiClient => {
- if (!options.ai) throw new Error('ANTHROPIC_API_KEY is needed to send or collect a batch.');
+ if (!options.ai) throw new Error('DOS_AI_API_KEY is needed to send or collect a batch.');
return options.ai;
};
@@ -738,16 +959,26 @@ export async function runReviewAiPointsJob(
try {
for await (const entry of ai.results(batch.id)) results.push(entry);
} catch (err) {
- // The API keeps results 29 days: an older batch is gone, so mark it and move on.
- if (now.getTime() - new Date(batch.submitted_at).getTime() < RESULTS_KEPT_MS) throw err;
+ // Results that will never come (the client says so, or the batch is past 28 days):
+ // close the row and store nothing, so the reviews are due again with no attempt spent.
+ const gone = err instanceof BatchGoneError;
+ if (!gone && now.getTime() - new Date(batch.submitted_at).getTime() < RESULTS_KEPT_MS) {
+ throw err;
+ }
+ const status = gone ? err.status : 'expired';
const { error } = await supabase
.from('review_ai_batches')
- .update({ status: 'expired', ended_at: now.toISOString() })
+ .update({ status, ended_at: now.toISOString() })
.eq('id', batch.id);
- if (error) throw new Error(`review_ai_batches expire: ${error.message}`);
- logger.warn('review_ai.expired', `Batch ${batch.id} results are gone; marked expired.`, {
- batch: batch.id,
- });
+ if (error) throw new Error(`review_ai_batches close: ${error.message}`);
+ logger.warn(
+ 'review_ai.gone',
+ `Batch ${batch.id} has no results to store; marked ${status}.`,
+ {
+ batch: batch.id,
+ reason: err instanceof Error ? err.message : String(err),
+ }
+ );
return false;
}
const ids = batch.manifest.flatMap(r => r.reviews.map(v => v.id));
@@ -808,6 +1039,7 @@ export async function runReviewAiPointsJob(
rejects: collected.rejects,
unaccented: collected.unaccented,
failed_reviews: collected.failedReviews,
+ unbilled_reviews: collected.unbilledReviews,
cost_usd: Number(collected.costUsd.toFixed(4)),
...counts,
});
@@ -879,7 +1111,7 @@ export async function runReviewAiPointsJob(
model,
requests.map((r, i) => ({
instructions: instructions[i]!,
- message: r.params.messages[0]!.content,
+ message: r.body.messages[1].content,
}))
);
const refreshed = await fetchByKey(
@@ -1055,9 +1287,15 @@ if (isDirectExecution) {
throw new Error(`The signed-in user has role '${role}', not rate_worker; refusing to run.`);
}
const supabase = scopeSupabaseToRate(client);
- // The model key is read only for a run that may call the API.
- const apiKey = (process.env.ANTHROPIC_API_KEY || '').trim();
- const ai = apply && apiKey ? await anthropicReviewAiClient(apiKey) : null;
+ // The gateway key is read only for a run that may call it. DOS_AI_BASE_URL alone
+ // overrides the gateway (beta-api.dos.ai, say); AI_BASE_URL is the other
+ // importer jobs' and may name another provider.
+ const apiKey = (process.env.DOS_AI_API_KEY || '').trim();
+ const baseUrl = (process.env.DOS_AI_BASE_URL || DOS_AI_DEFAULT_BASE_URL).trim();
+ if (!/^https:\/\/[^/\s]+(\/[^\s]*)?$/.test(baseUrl)) {
+ throw new Error('DOS_AI_BASE_URL must be an https URL.');
+ }
+ const ai = apply && apiKey ? dosAiReviewAiClient(apiKey, baseUrl) : null;
logger.info(
'review_ai.target',
`Target: ${host} (${apply ? 'apply' : 'dry run'}, ${enabled ? 'enabled' : 'disabled'}, ${modelName})`,
diff --git a/apps/importer/src/shopee/review-ai-prompt.ts b/apps/importer/src/shopee/review-ai-prompt.ts
index 48ab4855..36991a64 100644
--- a/apps/importer/src/shopee/review-ai-prompt.ts
+++ b/apps/importer/src/shopee/review-ai-prompt.ts
@@ -29,11 +29,11 @@ import {
export const REVIEW_AI_PROMPT_VERSION = 'v1';
export { RATING_CRITERIA_VERSION };
-/** Reviews per request: one category, one cached instruction block. */
+/** Reviews per request: one category, one instruction block (a shared prefix). */
export const REVIEWS_PER_REQUEST = 20;
/** Lines per side per review (design 2.2). */
export const AI_POINTS_PER_KIND = 3;
-/** Output tokens per request: 20 reviews x ~120 tokens, with room for low-effort thinking. */
+/** Output tokens per request: 20 reviews x ~120 tokens, with room for any reasoning. */
export const REQUEST_MAX_TOKENS = 6000;
/** Evidence length, in characters as stored (the writer takes 2 to 300). */
export const EVIDENCE_MAX = 300;
@@ -42,18 +42,31 @@ export const EVIDENCE_MAX = 300;
export const REVIEW_AI_MIN_CHARS = 40;
export const REVIEW_AI_MIN_WORDS = 8;
-/** Models the job may use (D1) and their list prices in USD per million tokens. */
+/**
+ * Models the job may use through the dos.ai gateway's Batch API (D2 as JOY
+ * decided on 2026-11-04: the job calls dos.ai, not a model vendor), and their
+ * dos.ai list prices in USD per million tokens (dosai.model_pricing, cents
+ * per million: dos 25/150, qwen3.8-max 200/600). The gateway's Batch API
+ * serves only its own and Alibaba models (dos, qwen3.8-*, deepseek-*); dos is
+ * Qwen3.8 27B on DOS's own GPUs. D1's shape stays: the cheap model by
+ * default, the stronger one only if the default misses the gold set (7.1).
+ */
export const REVIEW_AI_MODELS = {
- 'claude-haiku-5-5': { input: 0.1, output: 0.5, monthlyCapUsd: 5 },
- 'claude-sonnet-5-5': { input: 2, output: 10, monthlyCapUsd: 15 },
+ dos: { input: 0.25, output: 1.5, monthlyCapUsd: 5 },
+ 'qwen3.8-max': { input: 2, output: 6, monthlyCapUsd: 15 },
} as const;
export type ReviewAiModel = keyof typeof REVIEW_AI_MODELS;
-export const DEFAULT_REVIEW_AI_MODEL: ReviewAiModel = 'claude-haiku-5-5';
-/** The Batch API halves every token price. */
+export const DEFAULT_REVIEW_AI_MODEL: ReviewAiModel = 'dos';
+/** The gateway bills Batch API lines at half the synchronous price. */
export const BATCH_DISCOUNT = 0.5;
-/** Cache reads cost 0.1x the input price; cache writes 1.25x. */
-export const CACHE_READ_FACTOR = 0.1;
-export const CACHE_WRITE_FACTOR = 1.25;
+/**
+ * Cached prompt tokens, read or written, are counted at the full input price:
+ * the gateway reports them (prompt_tokens_details) but its cached rates per
+ * model are not published, so the job's figure is an upper bound. The
+ * gateway's own ledger is the bill.
+ */
+export const CACHE_READ_FACTOR = 1;
+export const CACHE_WRITE_FACTOR = 1;
export function isReviewAiModel(value: unknown): value is ReviewAiModel {
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(REVIEW_AI_MODELS, value);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 98f96597..f91e3db0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -130,9 +130,6 @@ importers:
apps/importer:
dependencies:
- '@anthropic-ai/sdk':
- specifier: 1.3.0
- version: 1.3.0(zod@4.6.7)
'@repo/shared-data':
specifier: workspace:*
version: link:../../packages/shared-data
@@ -350,15 +347,6 @@ packages:
resolution: {integrity: sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==}
engines: {node: '>=10'}
- '@anthropic-ai/sdk@1.3.0':
- resolution: {integrity: sha512-k7z9BlLLPxfZH+ov7dS6EnaN/wiZeV8YNT1LZfrpAHIy7oY7pHr4UzirF9f2OlHtFEnRBOX+AZQ91Y5fK+TPTw==}
- hasBin: true
- peerDependencies:
- zod: ^3.25.0 || ^4.0.0
- peerDependenciesMeta:
- zod:
- optional: true
-
'@axe-core/playwright@4.13.0':
resolution: {integrity: sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==}
peerDependencies:
@@ -1651,9 +1639,6 @@ packages:
resolution: {integrity: sha512-fCTuZK4QBa+39Oz9l4OGfJfz+GpwCp3AqO7Zch3to99xHPgstVsRFpeQ8LNd2o1Gv8raL2mCFwiaHh7bFSp5DQ==}
engines: {node: '>=22'}
- '@stablelib/base64@1.0.1':
- resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
-
'@supabase/auth-js@2.117.2':
resolution: {integrity: sha512-Z3WnGwrphYZubrLbxp5Iv0tLA9A5GhvKzJ/ZXglxqwd2QEH2R4dXRltjVJ8sIn4xEW2BSFIGT0yALVJIgZeDYw==}
engines: {node: '>=22.0.0'}
@@ -2725,9 +2710,6 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
- fast-sha256@1.3.0:
- resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
-
fast-uri@3.1.9:
resolution: {integrity: sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==}
@@ -3157,10 +3139,6 @@ packages:
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
- json-schema-to-ts@3.1.1:
- resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
- engines: {node: '>=16'}
-
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -3860,9 +3838,6 @@ packages:
resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==}
engines: {node: '>=6'}
- standardwebhooks@1.1.1:
- resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==}
-
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@@ -3981,9 +3956,6 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
- ts-algebra@2.0.0:
- resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
-
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@@ -4186,14 +4158,6 @@ snapshots:
'@alloc/quick-lru@5.3.0': {}
- '@anthropic-ai/sdk@1.3.0(zod@4.6.7)':
- dependencies:
- '@opentelemetry/api': 1.9.1
- json-schema-to-ts: 3.1.1
- standardwebhooks: 1.1.1
- optionalDependencies:
- zod: 4.6.7
-
'@axe-core/playwright@4.13.0(playwright-core@1.64.1)':
dependencies:
axe-core: 4.13.0
@@ -5261,8 +5225,6 @@ snapshots:
'@simple-libs/stream-utils@2.0.0': {}
- '@stablelib/base64@1.0.1': {}
-
'@supabase/auth-js@2.117.2':
dependencies:
tslib: 2.8.1
@@ -6461,8 +6423,6 @@ snapshots:
fast-levenshtein@2.0.6: {}
- fast-sha256@1.3.0: {}
-
fast-uri@3.1.9: {}
fastq@1.20.3:
@@ -6900,11 +6860,6 @@ snapshots:
json-parse-even-better-errors@2.3.1: {}
- json-schema-to-ts@3.1.1:
- dependencies:
- '@babel/runtime': 7.29.7
- ts-algebra: 2.0.0
-
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
@@ -7635,11 +7590,6 @@ snapshots:
dependencies:
type-fest: 0.7.1
- standardwebhooks@1.1.1:
- dependencies:
- '@stablelib/base64': 1.0.1
- fast-sha256: 1.3.0
-
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -7777,8 +7727,6 @@ snapshots:
dependencies:
is-number: 7.0.0
- ts-algebra@2.0.0: {}
-
ts-api-utils@2.5.0:
dependencies:
typescript: 6.0.3