mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-16 01:42:23 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9834a7a15 |
+1
-1
@@ -2387,7 +2387,7 @@ JOBS (Minions)
|
||||
jobs get <id> Job details + history
|
||||
jobs cancel <id> Cancel job
|
||||
jobs retry <id> Re-queue failed/dead job
|
||||
jobs prune [--older-than 30d] [--status s,..] Clean old terminal jobs (0d = no age floor)
|
||||
jobs prune [--older-than 30d] Clean old jobs
|
||||
jobs stats Job health dashboard
|
||||
jobs work [--queue Q] Start worker daemon (Postgres only)
|
||||
|
||||
|
||||
+5
-33
@@ -106,21 +106,6 @@ export function parseMaxRssFlag(args: string[]): number | undefined {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** Terminal statuses `jobs prune --status` accepts (PR #2282). Matches what
|
||||
* queue.prune can safely delete; anything else (waiting/active/…) is live. */
|
||||
export const PRUNE_STATUSES = ['completed', 'failed', 'dead', 'cancelled'] as const satisfies readonly MinionJobStatus[];
|
||||
|
||||
/** Parse a `--status a,b,c` value into prune statuses. Throws on any value
|
||||
* outside PRUNE_STATUSES (fail-fast, mirrors parseNiceValue). */
|
||||
export function parsePruneStatuses(raw: string): MinionJobStatus[] {
|
||||
const requested = raw.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const invalid = requested.filter(s => !(PRUNE_STATUSES as readonly string[]).includes(s));
|
||||
if (requested.length === 0 || invalid.length > 0) {
|
||||
throw new Error(`--status accepts a comma-separated subset of [${PRUNE_STATUSES.join(', ')}]${invalid.length ? `. Invalid: ${invalid.join(', ')}` : ''}`);
|
||||
}
|
||||
return requested as MinionJobStatus[];
|
||||
}
|
||||
|
||||
/** Parse `--nice N` (then `GBRAIN_NICE` env). Returns:
|
||||
* - undefined if absent (no priority change — inherit)
|
||||
* - the validated integer in [-20, 19] otherwise
|
||||
@@ -223,9 +208,7 @@ USAGE
|
||||
gbrain jobs get <id>
|
||||
gbrain jobs cancel <id>
|
||||
gbrain jobs retry <id>
|
||||
gbrain jobs prune [--older-than 30d] [--status completed,failed,dead,cancelled]
|
||||
(--older-than 0d = no age floor: deletes ALL
|
||||
matching terminal jobs; pair with --status)
|
||||
gbrain jobs prune [--older-than 30d]
|
||||
gbrain jobs delete <id>
|
||||
gbrain jobs stats
|
||||
gbrain jobs smoke
|
||||
@@ -617,27 +600,16 @@ HANDLER TYPES (built in)
|
||||
case 'prune': {
|
||||
const olderThanStr = parseFlag(args, '--older-than') ?? '30d';
|
||||
const days = parseInt(olderThanStr, 10);
|
||||
if (isNaN(days) || days < 0) {
|
||||
console.error('Error: --older-than must be a non-negative number (days). Example: --older-than 30d; --older-than 0d removes the age floor (deletes ALL matching terminal jobs).');
|
||||
if (isNaN(days) || days <= 0) {
|
||||
console.error('Error: --older-than must be a positive number (days). Example: --older-than 30d');
|
||||
process.exit(1);
|
||||
}
|
||||
const statusFlag = parseFlag(args, '--status');
|
||||
let statuses: MinionJobStatus[] | undefined;
|
||||
if (statusFlag !== undefined) {
|
||||
try { statuses = parsePruneStatuses(statusFlag); }
|
||||
catch (e) { console.error(`Error: ${e instanceof Error ? e.message : String(e)}`); process.exit(1); }
|
||||
}
|
||||
|
||||
try { await queue.ensureSchema(); }
|
||||
catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exit(1); }
|
||||
|
||||
const count = await queue.prune({
|
||||
olderThan: new Date(Date.now() - days * 86400000),
|
||||
...(statuses ? { status: statuses } : {}),
|
||||
});
|
||||
const statusLabel = statuses ? statuses.join('+') : 'completed+dead+cancelled';
|
||||
const ageLabel = days === 0 ? 'regardless of age' : `older than ${days} days`;
|
||||
console.log(`Pruned ${count} ${statusLabel} jobs ${ageLabel}.`);
|
||||
const count = await queue.prune({ olderThan: new Date(Date.now() - days * 86400000) });
|
||||
console.log(`Pruned ${count} jobs older than ${days} days.`);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,36 @@ export const litellmProxy: Recipe = {
|
||||
cost_per_1m_output_usd: undefined,
|
||||
price_last_verified: '2026-06-14',
|
||||
},
|
||||
// LiteLLM normalizes Cohere / Voyage / Jina / etc. rerank backends to the
|
||||
// same wire shape gbrain's gateway.rerank() already speaks (the
|
||||
// ZeroEntropy/llama.cpp contract):
|
||||
// { model, query, documents, top_n } → { results: [{ index, relevance_score }] }
|
||||
// So any rerank model the user registers in their LiteLLM config is
|
||||
// reachable via `gbrain config set search.reranker.model litellm:<model>`
|
||||
// with no request/response adapter — same as embeddings ride the proxy.
|
||||
reranker: {
|
||||
models: [], // user-provided; whatever rerank models the proxy serves
|
||||
// No canonical default — the proxy defines its own model ids. The user
|
||||
// sets search.reranker.model explicitly (mirrors the embedding
|
||||
// touchpoint's user_provided_models contract).
|
||||
default_model: '',
|
||||
// The proxied backend bills (Cohere/Voyage/…); pricing-unknown is the
|
||||
// honest state — same stance as this recipe's embedding/chat
|
||||
// touchpoints and budget-tracker's deliberate litellm exclusion from
|
||||
// the free-provider sets.
|
||||
cost_per_1m_tokens_usd: undefined,
|
||||
price_last_verified: '2026-06-27',
|
||||
max_payload_bytes: 5_000_000,
|
||||
// LEAF path only (matches llama-server-reranker's convention). LiteLLM
|
||||
// serves both `/rerank` and `/v1/rerank`, and LITELLM_BASE_URL may be
|
||||
// set with or without the `/v1` suffix (the setup_hint allows both), so
|
||||
// the leaf form yields a valid route either way:
|
||||
// http://localhost:4000 + /rerank → /rerank ✓
|
||||
// http://localhost:4000/v1 + /rerank → /v1/rerank ✓
|
||||
// Pinning '/v1/rerank' here would double to /v1/v1/rerank → 404 on
|
||||
// /v1-suffixed bases.
|
||||
path: '/rerank',
|
||||
},
|
||||
},
|
||||
setup_hint: 'Run LiteLLM (https://docs.litellm.ai) in front of any provider; set LITELLM_BASE_URL (include the /v1 suffix if your proxy serves the OpenAI route there, e.g. http://localhost:4000/v1) + pass --embedding-model litellm:<model> and --embedding-dimensions <N>.',
|
||||
setup_hint: 'Run LiteLLM (https://docs.litellm.ai) in front of any provider; set LITELLM_BASE_URL (include the /v1 suffix if your proxy serves the OpenAI route there, e.g. http://localhost:4000/v1) + pass --embedding-model litellm:<model> and --embedding-dimensions <N>. For rerank: register a rerank model in LiteLLM and set search.reranker.model litellm:<model-name>.',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* litellm-proxy reranker touchpoint smoke.
|
||||
*
|
||||
* Sibling of recipe-llama-server-reranker.test.ts. Pins the reranker
|
||||
* touchpoint on the LiteLLM proxy recipe so:
|
||||
* - the touchpoint exists with the LEAF '/rerank' path (LiteLLM serves both
|
||||
* /rerank and /v1/rerank, so the leaf form is valid whether or not the
|
||||
* user's LITELLM_BASE_URL carries the /v1 suffix the setup_hint allows)
|
||||
* - a /v1-suffixed base URL does NOT produce /v1/v1/rerank (the original
|
||||
* community PR pinned '/v1/rerank' which 404s on /v1-suffixed bases)
|
||||
* - models: [] (user-provided; proxy defines the model ids)
|
||||
* - pricing stays undefined (proxy can front a paid provider — same honest
|
||||
* pricing-unknown stance as the embedding/chat touchpoints)
|
||||
*
|
||||
* The gateway.rerank() URL tests drive the real URL builder via the stubbed
|
||||
* transport (same seam as test/ai/rerank.test.ts).
|
||||
*/
|
||||
|
||||
import { describe, expect, test, afterEach } from 'bun:test';
|
||||
import { getRecipe } from '../../src/core/ai/recipes/index.ts';
|
||||
import {
|
||||
configureGateway,
|
||||
resetGateway,
|
||||
rerank,
|
||||
__setRerankTransportForTests,
|
||||
} from '../../src/core/ai/gateway.ts';
|
||||
|
||||
afterEach(() => {
|
||||
__setRerankTransportForTests(null);
|
||||
resetGateway();
|
||||
});
|
||||
|
||||
describe('recipe: litellm reranker touchpoint', () => {
|
||||
test('declares reranker touchpoint with leaf /rerank path', () => {
|
||||
const r = getRecipe('litellm')!;
|
||||
const tp = r.touchpoints.reranker;
|
||||
expect(tp).toBeDefined();
|
||||
expect(tp!.path).toBe('/rerank');
|
||||
expect(tp!.max_payload_bytes).toBe(5_000_000);
|
||||
});
|
||||
|
||||
test('reranker touchpoint uses empty models[] for user-provided model ids', () => {
|
||||
const r = getRecipe('litellm')!;
|
||||
expect(r.touchpoints.reranker!.models).toEqual([]);
|
||||
});
|
||||
|
||||
test('pricing stays undefined — proxy can front a paid provider', () => {
|
||||
const r = getRecipe('litellm')!;
|
||||
expect(r.touchpoints.reranker!.cost_per_1m_tokens_usd).toBeUndefined();
|
||||
});
|
||||
|
||||
test('setup_hint keeps the /v1-suffix guidance AND mentions rerank', () => {
|
||||
const r = getRecipe('litellm')!;
|
||||
expect(r.setup_hint).toMatch(/\/v1 suffix/);
|
||||
expect(r.setup_hint).toMatch(/search\.reranker\.model litellm:/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gateway.rerank() URL via litellm recipe', () => {
|
||||
async function capturedRerankUrl(baseUrl?: string): Promise<string> {
|
||||
configureGateway({
|
||||
reranker_model: 'litellm:my-reranker',
|
||||
env: {},
|
||||
...(baseUrl ? { base_urls: { litellm: baseUrl } } : {}),
|
||||
});
|
||||
let capturedUrl = '';
|
||||
__setRerankTransportForTests(async (url) => {
|
||||
capturedUrl = url;
|
||||
return new Response(
|
||||
JSON.stringify({ results: [{ index: 0, relevance_score: 0.9 }] }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
);
|
||||
});
|
||||
await rerank({ query: 'q', documents: ['d'] });
|
||||
return capturedUrl;
|
||||
}
|
||||
|
||||
test('default base (no /v1 suffix) → /rerank', async () => {
|
||||
const url = await capturedRerankUrl();
|
||||
expect(url).toBe('http://localhost:4000/rerank');
|
||||
});
|
||||
|
||||
test('/v1-suffixed base → /v1/rerank, NOT /v1/v1/rerank', async () => {
|
||||
const url = await capturedRerankUrl('http://localhost:4000/v1');
|
||||
expect(url).toBe('http://localhost:4000/v1/rerank');
|
||||
expect(url).not.toContain('/v1/v1/');
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
/**
|
||||
* Unit tests for parsePruneStatuses (PR #2282) — `jobs prune --status` parsing.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { parsePruneStatuses, PRUNE_STATUSES } from '../src/commands/jobs.ts';
|
||||
|
||||
describe('parsePruneStatuses', () => {
|
||||
test('parses a single status', () => {
|
||||
expect(parsePruneStatuses('failed')).toEqual(['failed']);
|
||||
});
|
||||
|
||||
test('parses a comma-separated list with whitespace', () => {
|
||||
expect(parsePruneStatuses(' completed, dead ')).toEqual(['completed', 'dead']);
|
||||
});
|
||||
|
||||
test('accepts every documented terminal status', () => {
|
||||
expect(parsePruneStatuses(PRUNE_STATUSES.join(','))).toEqual([...PRUNE_STATUSES]);
|
||||
});
|
||||
|
||||
test('throws on non-terminal statuses', () => {
|
||||
expect(() => parsePruneStatuses('waiting')).toThrow(/Invalid: waiting/);
|
||||
expect(() => parsePruneStatuses('completed,active')).toThrow(/Invalid: active/);
|
||||
});
|
||||
|
||||
test('throws on empty value', () => {
|
||||
expect(() => parsePruneStatuses('')).toThrow(/comma-separated subset/);
|
||||
expect(() => parsePruneStatuses(',')).toThrow(/comma-separated subset/);
|
||||
});
|
||||
});
|
||||
@@ -702,32 +702,6 @@ describe('MinionQueue: Prune', () => {
|
||||
const count = await queue.prune({ olderThan: new Date(Date.now() + 86400000) }); // future date = prune everything old enough
|
||||
expect(count).toBe(1); // only the cancelled one
|
||||
});
|
||||
|
||||
// PR #2282: `jobs prune --status` passes an explicit status subset through.
|
||||
test('status filter prunes only the requested terminal statuses', async () => {
|
||||
const cancelled = await queue.add('sync', {});
|
||||
await queue.cancelJob(cancelled.id);
|
||||
const dead = await queue.add('embed', {}, { max_attempts: 1 });
|
||||
await queue.claim('tok1', 30000, 'default', ['embed']);
|
||||
await queue.failJob(dead.id, 'tok1', 'boom', 'dead');
|
||||
|
||||
const count = await queue.prune({ olderThan: new Date(Date.now() + 86400000), status: ['dead'] });
|
||||
expect(count).toBe(1); // only the dead one
|
||||
|
||||
const remaining = await queue.getJobs({ status: 'cancelled' });
|
||||
expect(remaining.length).toBe(1);
|
||||
});
|
||||
|
||||
// PR #2282: `--older-than 0d` = no age floor — olderThan of "now" deletes
|
||||
// terminal jobs that finished moments ago.
|
||||
test('olderThan now (0d semantics) prunes just-terminated jobs', async () => {
|
||||
const job = await queue.add('sync', {});
|
||||
await queue.cancelJob(job.id);
|
||||
await new Promise(r => setTimeout(r, 5)); // ensure updated_at < now
|
||||
|
||||
const count = await queue.prune({ olderThan: new Date() });
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Stats (1 test) ---
|
||||
|
||||
Reference in New Issue
Block a user