jerob 1.0.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/CLI/cli.ts +42 -0
- package/README.md +137 -0
- package/SETUP.md +584 -0
- package/agent/action-tracker.ts +45 -0
- package/agent/agent-tools.ts +111 -0
- package/agent/approval.ts +137 -0
- package/agent/diff-view.ts +26 -0
- package/agent/orchestrator.ts +186 -0
- package/agent/tool-executor.ts +463 -0
- package/agent/types.ts +69 -0
- package/ask/orchestrator.ts +244 -0
- package/auth/auth.ts +567 -0
- package/auth/config-store.ts +77 -0
- package/auth/crypto.ts +51 -0
- package/auth/env-writer.ts +82 -0
- package/bin/jerob.js +28 -0
- package/config/ai.config.ts +163 -0
- package/email_ops/email-tools.ts +178 -0
- package/email_ops/email_functions.ts +443 -0
- package/email_ops/email_init.ts +92 -0
- package/email_ops/email_pass_store.ts +61 -0
- package/email_ops/email_server.ts +29 -0
- package/email_ops/types.ts +88 -0
- package/index.ts +176 -0
- package/package.json +88 -0
- package/plan/browser-agent/README.md +118 -0
- package/plan/browser-agent/USAGE.md +308 -0
- package/plan/browser-agent/evaluator.ts +353 -0
- package/plan/browser-agent/executor.ts +372 -0
- package/plan/browser-agent/index.ts +13 -0
- package/plan/browser-agent/orchestrator.ts +323 -0
- package/plan/browser-agent/planner.ts +200 -0
- package/plan/browser-agent/types.ts +62 -0
- package/plan/browser-tool.ts +128 -0
- package/plan/index.ts +12 -0
- package/plan/orchestrator.ts +214 -0
- package/plan/planner.ts +183 -0
- package/plan/selection.ts +50 -0
- package/plan/types.ts +13 -0
- package/plan/web-tools.ts +119 -0
- package/scheduler/ARCHITECTURE.md +263 -0
- package/scheduler/README.md +200 -0
- package/scheduler/SETUP-READY.sql +84 -0
- package/scheduler/check-status.sql +124 -0
- package/scheduler/config-sync.ts +91 -0
- package/scheduler/db-migrate.ts +271 -0
- package/scheduler/db.ts +162 -0
- package/scheduler/debug.ts +184 -0
- package/scheduler/orchestrator.ts +438 -0
- package/scheduler/planner.ts +170 -0
- package/scheduler/update-task-email.ts +70 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/.temp/gotrue-version +1 -0
- package/supabase/.temp/linked-project.json +1 -0
- package/supabase/.temp/pooler-url +1 -0
- package/supabase/.temp/postgres-version +1 -0
- package/supabase/.temp/project-ref +1 -0
- package/supabase/.temp/rest-version +1 -0
- package/supabase/.temp/storage-migration +1 -0
- package/supabase/.temp/storage-version +1 -0
- package/supabase/deploy.ps1 +50 -0
- package/supabase/functions/scheduler-tick/index.ts +496 -0
- package/supabase/supabase/.temp/linked-project.json +1 -0
- package/tsconfig.json +33 -0
- package/tui/spinner.ts +33 -0
- package/tui/spinup.ts +67 -0
- package/tui/terminal-render.ts +16 -0
- package/utils/llm-error.ts +185 -0
- package/utils/model-validator.ts +247 -0
package/tui/spinup.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { select, isCancel } from "@clack/prompts";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import figlet from "figlet";
|
|
4
|
+
import { runCLIMode } from "../CLI/cli";
|
|
5
|
+
import { runTelegramMode } from "../Telegram";
|
|
6
|
+
|
|
7
|
+
const HF = "ANSI Shadow";
|
|
8
|
+
const SHADOW = chalk.hex("#5941de");
|
|
9
|
+
const FACE = chalk.hex("#eeeffff").bold;
|
|
10
|
+
|
|
11
|
+
function printBannerWithShadow(ascii: string) {
|
|
12
|
+
const bannerLines = ascii.replace(/\s+$/, "").split("\n");
|
|
13
|
+
const maxLen = Math.max(...bannerLines.map((l) => l.length), 0);
|
|
14
|
+
const rowWidth = maxLen + 2;
|
|
15
|
+
|
|
16
|
+
for (const line of bannerLines) {
|
|
17
|
+
console.log(SHADOW((" " + line).padEnd(rowWidth)));
|
|
18
|
+
}
|
|
19
|
+
process.stdout.write(`\x1b[${bannerLines.length}A`);
|
|
20
|
+
for (const line of bannerLines) {
|
|
21
|
+
console.log(FACE(line.padEnd(rowWidth)));
|
|
22
|
+
}
|
|
23
|
+
console.log();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const startArena = async () => {
|
|
27
|
+
let ascii: string;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
ascii = figlet.textSync("Jerob", { font: HF });
|
|
31
|
+
} catch (e) {
|
|
32
|
+
ascii = figlet.textSync("Jerob", { font: "Standard" });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
printBannerWithShadow(ascii);
|
|
36
|
+
|
|
37
|
+
while (true) {
|
|
38
|
+
const option = await select({
|
|
39
|
+
message: chalk.green("choose way to communicate"),
|
|
40
|
+
options: [
|
|
41
|
+
{ value: "CLI", label: "CLI" },
|
|
42
|
+
{ value: "Telegram", label: "Telegram" },
|
|
43
|
+
{ value: "Exit", label: "Exit" },
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (isCancel(option) || option == "Exit") {
|
|
48
|
+
console.log(chalk.green.bold("Goodbye !!!"));
|
|
49
|
+
return "END";
|
|
50
|
+
}
|
|
51
|
+
if (option == "CLI") {
|
|
52
|
+
try {
|
|
53
|
+
await runCLIMode();
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.log(chalk.red(`\n✖ CLI error: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
} else if (option == "Telegram") {
|
|
59
|
+
try {
|
|
60
|
+
await runTelegramMode();
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.log(chalk.red(`\n✖ Telegram error: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
63
|
+
}
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { marked } from 'marked';
|
|
2
|
+
import { markedTerminal } from 'marked-terminal';
|
|
3
|
+
|
|
4
|
+
let ready=false;
|
|
5
|
+
|
|
6
|
+
function ensureMarked():void{
|
|
7
|
+
if(ready) return;
|
|
8
|
+
const w=Math.max(40,Math.min(process.stdout.columns || 80,120));
|
|
9
|
+
// @ts-ignore
|
|
10
|
+
marked.use(markedTerminal({width:w,reflowText: true},{}));
|
|
11
|
+
ready=true;
|
|
12
|
+
}
|
|
13
|
+
export const renderHTMLMarkdown=(content:string): string=>{
|
|
14
|
+
ensureMarked()
|
|
15
|
+
return marked.parse(content.trimEnd(),{async:false}) as string
|
|
16
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
export interface LLMError {
|
|
4
|
+
type: "rate_limit" | "quota" | "auth" | "network" | "refusal" | "timeout" | "unknown";
|
|
5
|
+
message: string;
|
|
6
|
+
retryable: boolean;
|
|
7
|
+
retryAfterMs?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse any LLM/API error into a structured, user-friendly form.
|
|
12
|
+
*/
|
|
13
|
+
export function parseLLMError(err: unknown): LLMError {
|
|
14
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
15
|
+
// Log the raw provider error so we can see exactly what the API returned
|
|
16
|
+
console.error(chalk.dim(`[LLM raw error] ${raw}`));
|
|
17
|
+
const lower = raw.toLowerCase();
|
|
18
|
+
|
|
19
|
+
// Rate limit / quota
|
|
20
|
+
if (
|
|
21
|
+
lower.includes("rate limit") ||
|
|
22
|
+
lower.includes("ratelimit") ||
|
|
23
|
+
lower.includes("rate_limit") ||
|
|
24
|
+
lower.includes("429") ||
|
|
25
|
+
lower.includes("too many requests")
|
|
26
|
+
) {
|
|
27
|
+
// Try to extract retry-after seconds from message
|
|
28
|
+
const retryMatch = raw.match(/retry after (\d+)/i) || raw.match(/(\d+)\s*second/i);
|
|
29
|
+
const retryAfterMs = retryMatch ? parseInt(retryMatch[1]!) * 1000 : 10_000;
|
|
30
|
+
return {
|
|
31
|
+
type: "rate_limit",
|
|
32
|
+
message: "Rate limit reached. The model is temporarily throttled.",
|
|
33
|
+
retryable: true,
|
|
34
|
+
retryAfterMs,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Quota exceeded
|
|
39
|
+
if (
|
|
40
|
+
lower.includes("quota") ||
|
|
41
|
+
lower.includes("insufficient_quota") ||
|
|
42
|
+
lower.includes("billing") ||
|
|
43
|
+
lower.includes("credits") ||
|
|
44
|
+
lower.includes("402")
|
|
45
|
+
) {
|
|
46
|
+
return {
|
|
47
|
+
type: "quota",
|
|
48
|
+
message: "API quota or credits exhausted. Check your billing at openrouter.ai or groq.com.",
|
|
49
|
+
retryable: false,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Auth
|
|
54
|
+
if (
|
|
55
|
+
lower.includes("401") ||
|
|
56
|
+
lower.includes("403") ||
|
|
57
|
+
lower.includes("unauthorized") ||
|
|
58
|
+
lower.includes("invalid api key") ||
|
|
59
|
+
lower.includes("invalid_api_key") ||
|
|
60
|
+
lower.includes("authentication")
|
|
61
|
+
) {
|
|
62
|
+
return {
|
|
63
|
+
type: "auth",
|
|
64
|
+
message: "Invalid or missing API key. Run `jimmy set-key` to update it.",
|
|
65
|
+
retryable: false,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Timeout / network
|
|
70
|
+
if (
|
|
71
|
+
lower.includes("timeout") ||
|
|
72
|
+
lower.includes("timed out") ||
|
|
73
|
+
lower.includes("econnrefused") ||
|
|
74
|
+
lower.includes("enotfound") ||
|
|
75
|
+
lower.includes("network") ||
|
|
76
|
+
lower.includes("fetch failed") ||
|
|
77
|
+
lower.includes("504") ||
|
|
78
|
+
lower.includes("503") ||
|
|
79
|
+
lower.includes("502")
|
|
80
|
+
) {
|
|
81
|
+
return {
|
|
82
|
+
type: "timeout",
|
|
83
|
+
message: "Network or server timeout. Check your internet connection and try again.",
|
|
84
|
+
retryable: true,
|
|
85
|
+
retryAfterMs: 3_000,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Model refusal / content policy
|
|
90
|
+
if (
|
|
91
|
+
lower.includes("content policy") ||
|
|
92
|
+
lower.includes("safety") ||
|
|
93
|
+
lower.includes("i'm sorry") ||
|
|
94
|
+
lower.includes("i cannot") ||
|
|
95
|
+
lower.includes("i can't help") ||
|
|
96
|
+
lower.includes("cannot assist")
|
|
97
|
+
) {
|
|
98
|
+
return {
|
|
99
|
+
type: "refusal",
|
|
100
|
+
message: "The model refused this request due to content policy. Try rephrasing your prompt.",
|
|
101
|
+
retryable: false,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
type: "unknown",
|
|
107
|
+
message: raw.slice(0, 300),
|
|
108
|
+
retryable: false,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Print a formatted, user-friendly LLM error to the console.
|
|
114
|
+
*/
|
|
115
|
+
export function printLLMError(err: unknown, context?: string): void {
|
|
116
|
+
const parsed = parseLLMError(err);
|
|
117
|
+
|
|
118
|
+
const prefix = context ? chalk.dim(`[${context}] `) : "";
|
|
119
|
+
|
|
120
|
+
switch (parsed.type) {
|
|
121
|
+
case "rate_limit":
|
|
122
|
+
console.log(chalk.yellow(`\n${prefix}⏱ Rate limited — ${parsed.message}`));
|
|
123
|
+
if (parsed.retryAfterMs) {
|
|
124
|
+
console.log(chalk.dim(` Retry in ~${Math.ceil(parsed.retryAfterMs / 1000)}s`));
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
case "quota":
|
|
128
|
+
console.log(chalk.red(`\n${prefix}💳 Quota exceeded — ${parsed.message}`));
|
|
129
|
+
break;
|
|
130
|
+
case "auth":
|
|
131
|
+
console.log(chalk.red(`\n${prefix}🔑 Auth error — ${parsed.message}`));
|
|
132
|
+
break;
|
|
133
|
+
case "timeout":
|
|
134
|
+
console.log(chalk.yellow(`\n${prefix}🌐 Network error — ${parsed.message}`));
|
|
135
|
+
break;
|
|
136
|
+
case "refusal":
|
|
137
|
+
console.log(chalk.yellow(`\n${prefix}🚫 Model refusal — ${parsed.message}`));
|
|
138
|
+
break;
|
|
139
|
+
default:
|
|
140
|
+
console.log(chalk.red(`\n${prefix}❌ LLM error — ${parsed.message}`));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Retry an LLM operation with exponential backoff.
|
|
146
|
+
* Stops immediately on non-retryable errors (auth, quota, refusal).
|
|
147
|
+
*/
|
|
148
|
+
export async function withLLMRetry<T>(
|
|
149
|
+
operation: () => Promise<T>,
|
|
150
|
+
options: {
|
|
151
|
+
maxRetries?: number;
|
|
152
|
+
baseDelayMs?: number;
|
|
153
|
+
context?: string;
|
|
154
|
+
} = {}
|
|
155
|
+
): Promise<T> {
|
|
156
|
+
const { maxRetries = 3, baseDelayMs = 1_500, context } = options;
|
|
157
|
+
let lastError: unknown;
|
|
158
|
+
|
|
159
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
160
|
+
try {
|
|
161
|
+
return await operation();
|
|
162
|
+
} catch (err) {
|
|
163
|
+
lastError = err;
|
|
164
|
+
const parsed = parseLLMError(err);
|
|
165
|
+
|
|
166
|
+
if (!parsed.retryable) {
|
|
167
|
+
printLLMError(err, context);
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (attempt === maxRetries) break;
|
|
172
|
+
|
|
173
|
+
const delay = parsed.retryAfterMs ?? baseDelayMs * Math.pow(2, attempt);
|
|
174
|
+
console.log(
|
|
175
|
+
chalk.dim(
|
|
176
|
+
` ${context ? `[${context}] ` : ""}Retrying in ${Math.ceil(delay / 1000)}s (attempt ${attempt + 1}/${maxRetries})…`
|
|
177
|
+
)
|
|
178
|
+
);
|
|
179
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
printLLMError(lastError, context);
|
|
184
|
+
throw lastError;
|
|
185
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model ID validation for all supported providers.
|
|
3
|
+
*
|
|
4
|
+
* Strategy:
|
|
5
|
+
* - Each provider has a curated list of known-valid models.
|
|
6
|
+
* - If the input matches a known model → valid.
|
|
7
|
+
* - If the input passes the provider's format rules → warn but allow (future models).
|
|
8
|
+
* - If neither → error with suggestions.
|
|
9
|
+
*
|
|
10
|
+
* No live API calls are made — this is intentional to keep setup fast and offline-capable.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type Provider = "openrouter" | "gemini" | "claude" | "openai" | "groq";
|
|
14
|
+
|
|
15
|
+
// ── Known valid models per provider ──────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export const KNOWN_MODELS: Record<Provider, string[]> = {
|
|
18
|
+
openrouter: [
|
|
19
|
+
// Anthropic via OpenRouter
|
|
20
|
+
"anthropic/claude-3.5-sonnet",
|
|
21
|
+
"anthropic/claude-3.5-haiku",
|
|
22
|
+
"anthropic/claude-3-opus",
|
|
23
|
+
"anthropic/claude-3-haiku",
|
|
24
|
+
// OpenAI via OpenRouter
|
|
25
|
+
"openai/gpt-4o",
|
|
26
|
+
"openai/gpt-4o-mini",
|
|
27
|
+
"openai/gpt-4-turbo",
|
|
28
|
+
"openai/o1-preview",
|
|
29
|
+
"openai/o1-mini",
|
|
30
|
+
// Google via OpenRouter
|
|
31
|
+
"google/gemini-pro-1.5",
|
|
32
|
+
"google/gemini-flash-1.5",
|
|
33
|
+
"google/gemini-2.0-flash-exp:free",
|
|
34
|
+
// Meta via OpenRouter
|
|
35
|
+
"meta-llama/llama-3.1-8b-instruct:free",
|
|
36
|
+
"meta-llama/llama-3.1-70b-instruct",
|
|
37
|
+
"meta-llama/llama-3.3-70b-instruct",
|
|
38
|
+
// Mistral via OpenRouter
|
|
39
|
+
"mistralai/mistral-7b-instruct:free",
|
|
40
|
+
"mistralai/mistral-large",
|
|
41
|
+
"mistralai/mixtral-8x7b-instruct",
|
|
42
|
+
// Qwen
|
|
43
|
+
"qwen/qwen-2.5-72b-instruct",
|
|
44
|
+
"qwen/qwen-2.5-7b-instruct:free",
|
|
45
|
+
// Free routing
|
|
46
|
+
"openrouter/free",
|
|
47
|
+
],
|
|
48
|
+
|
|
49
|
+
gemini: [
|
|
50
|
+
"gemini-2.0-flash",
|
|
51
|
+
"gemini-2.0-flash-exp",
|
|
52
|
+
"gemini-1.5-pro",
|
|
53
|
+
"gemini-1.5-flash",
|
|
54
|
+
"gemini-1.5-flash-8b",
|
|
55
|
+
"gemini-1.0-pro",
|
|
56
|
+
"gemini-pro",
|
|
57
|
+
"gemini-3.1-flash-lite-preview",
|
|
58
|
+
"gemini-2.0-flash-thinking-exp",
|
|
59
|
+
],
|
|
60
|
+
|
|
61
|
+
claude: [
|
|
62
|
+
"claude-3-5-sonnet-20241022",
|
|
63
|
+
"claude-3-5-haiku-20241022",
|
|
64
|
+
"claude-3-opus-20240229",
|
|
65
|
+
"claude-3-sonnet-20240229",
|
|
66
|
+
"claude-3-haiku-20240307",
|
|
67
|
+
"claude-2.1",
|
|
68
|
+
"claude-instant-1.2",
|
|
69
|
+
],
|
|
70
|
+
|
|
71
|
+
openai: [
|
|
72
|
+
"gpt-4o",
|
|
73
|
+
"gpt-4o-mini",
|
|
74
|
+
"gpt-4-turbo",
|
|
75
|
+
"gpt-4-turbo-preview",
|
|
76
|
+
"gpt-4",
|
|
77
|
+
"gpt-3.5-turbo",
|
|
78
|
+
"gpt-3.5-turbo-16k",
|
|
79
|
+
"o1-preview",
|
|
80
|
+
"o1-mini",
|
|
81
|
+
"o3-mini",
|
|
82
|
+
],
|
|
83
|
+
|
|
84
|
+
groq: [
|
|
85
|
+
"llama-3.3-70b-versatile",
|
|
86
|
+
"llama-3.1-8b-instant",
|
|
87
|
+
"llama-3.1-70b-versatile",
|
|
88
|
+
"llama3-8b-8192",
|
|
89
|
+
"llama3-70b-8192",
|
|
90
|
+
"mixtral-8x7b-32768",
|
|
91
|
+
"gemma2-9b-it",
|
|
92
|
+
"gemma-7b-it",
|
|
93
|
+
"whisper-large-v3",
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ── Format rules per provider ─────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
const FORMAT_RULES: Record<Provider, { pattern: RegExp; hint: string }> = {
|
|
100
|
+
openrouter: {
|
|
101
|
+
pattern: /^[a-z0-9_-]+\/[a-z0-9._:-]+$/i,
|
|
102
|
+
hint: "Must be in format provider/model-name (e.g. openai/gpt-4o, meta-llama/llama-3.1-8b-instruct:free)",
|
|
103
|
+
},
|
|
104
|
+
gemini: {
|
|
105
|
+
pattern: /^gemini[-\w.]+$/i,
|
|
106
|
+
hint: "Must start with 'gemini' (e.g. gemini-2.0-flash, gemini-1.5-pro)",
|
|
107
|
+
},
|
|
108
|
+
claude: {
|
|
109
|
+
pattern: /^claude[-\w.]+$/i,
|
|
110
|
+
hint: "Must start with 'claude' (e.g. claude-3-5-sonnet-20241022)",
|
|
111
|
+
},
|
|
112
|
+
openai: {
|
|
113
|
+
pattern: /^(gpt|o[0-9])[-\w.]+$/i,
|
|
114
|
+
hint: "Must start with 'gpt-' or 'o1/'o3' (e.g. gpt-4o, o1-mini)",
|
|
115
|
+
},
|
|
116
|
+
groq: {
|
|
117
|
+
pattern: /^[a-z0-9][-\w.]+$/i,
|
|
118
|
+
hint: "e.g. llama-3.3-70b-versatile, mixtral-8x7b-32768",
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// ── Public validator ──────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export interface ValidationResult {
|
|
125
|
+
valid: boolean;
|
|
126
|
+
error?: string; // hard error — don't allow
|
|
127
|
+
warning?: string; // soft warning — allow but inform
|
|
128
|
+
suggestions?: string[]; // closest known models
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Validates a model ID for a given provider.
|
|
133
|
+
* Returns { valid: true } if known, { valid: true, warning } if format-valid but unknown,
|
|
134
|
+
* { valid: false, error } if format is wrong.
|
|
135
|
+
*/
|
|
136
|
+
export function validateModel(provider: Provider, modelId: string): ValidationResult {
|
|
137
|
+
const s = modelId.trim();
|
|
138
|
+
|
|
139
|
+
if (!s) {
|
|
140
|
+
return { valid: false, error: "Model ID cannot be empty" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (s.length > 150) {
|
|
144
|
+
return { valid: false, error: "Model ID is too long (max 150 characters)" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Check known list first
|
|
148
|
+
const known = KNOWN_MODELS[provider];
|
|
149
|
+
if (known.includes(s)) {
|
|
150
|
+
return { valid: true };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check format
|
|
154
|
+
const rule = FORMAT_RULES[provider];
|
|
155
|
+
if (!rule.pattern.test(s)) {
|
|
156
|
+
const suggestions = closestMatches(s, known);
|
|
157
|
+
return {
|
|
158
|
+
valid: false,
|
|
159
|
+
error: `Invalid format for ${provider}. ${rule.hint}`,
|
|
160
|
+
suggestions,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Format valid but not in known list — allow with warning
|
|
165
|
+
const suggestions = closestMatches(s, known);
|
|
166
|
+
return {
|
|
167
|
+
valid: true,
|
|
168
|
+
warning: `"${s}" is not in the known model list for ${provider}. It may work if it's a new model.`,
|
|
169
|
+
suggestions,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Find up to 3 closest known models by string similarity */
|
|
174
|
+
function closestMatches(input: string, known: string[]): string[] {
|
|
175
|
+
const lower = input.toLowerCase();
|
|
176
|
+
return known
|
|
177
|
+
.map((k) => ({ k, score: similarity(lower, k.toLowerCase()) }))
|
|
178
|
+
.sort((a, b) => b.score - a.score)
|
|
179
|
+
.slice(0, 3)
|
|
180
|
+
.filter((x) => x.score > 0.2)
|
|
181
|
+
.map((x) => x.k);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Simple bigram similarity (Sørensen–Dice coefficient) */
|
|
185
|
+
function similarity(a: string, b: string): number {
|
|
186
|
+
if (a === b) return 1;
|
|
187
|
+
if (a.length < 2 || b.length < 2) return 0;
|
|
188
|
+
|
|
189
|
+
const bigrams = (s: string) => {
|
|
190
|
+
const set = new Map<string, number>();
|
|
191
|
+
for (let i = 0; i < s.length - 1; i++) {
|
|
192
|
+
const bg = s.slice(i, i + 2);
|
|
193
|
+
set.set(bg, (set.get(bg) ?? 0) + 1);
|
|
194
|
+
}
|
|
195
|
+
return set;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const aGrams = bigrams(a);
|
|
199
|
+
const bGrams = bigrams(b);
|
|
200
|
+
let intersection = 0;
|
|
201
|
+
|
|
202
|
+
for (const [bg, count] of aGrams) {
|
|
203
|
+
const bCount = bGrams.get(bg) ?? 0;
|
|
204
|
+
intersection += Math.min(count, bCount);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return (2 * intersection) / (a.length - 1 + (b.length - 1));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Returns a validate function compatible with @clack/prompts `text.validate`.
|
|
212
|
+
* Shows hard errors, shows warnings inline, allows the user to proceed.
|
|
213
|
+
*/
|
|
214
|
+
export function clackModelValidator(
|
|
215
|
+
provider: Provider
|
|
216
|
+
): (value: string | undefined) => string | undefined {
|
|
217
|
+
return (value) => {
|
|
218
|
+
const v = value?.trim() ?? "";
|
|
219
|
+
if (!v) return "Model ID required";
|
|
220
|
+
const result = validateModel(provider, v);
|
|
221
|
+
if (!result.valid) {
|
|
222
|
+
const base = result.error!;
|
|
223
|
+
const hint = result.suggestions?.length
|
|
224
|
+
? `\n Did you mean: ${result.suggestions.join(", ")}?`
|
|
225
|
+
: "";
|
|
226
|
+
return base + hint;
|
|
227
|
+
}
|
|
228
|
+
// warnings are shown after the prompt, not as validation errors
|
|
229
|
+
return undefined;
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Print a validation warning to the console (called after text() resolves).
|
|
235
|
+
*/
|
|
236
|
+
export function printModelWarning(provider: Provider, modelId: string): void {
|
|
237
|
+
const result = validateModel(provider, modelId.trim());
|
|
238
|
+
if (result.warning) {
|
|
239
|
+
console.log(chalk.yellow(` ⚠ ${result.warning}`));
|
|
240
|
+
if (result.suggestions?.length) {
|
|
241
|
+
console.log(chalk.dim(` Known models: ${result.suggestions.join(", ")}`));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// chalk is used only in printModelWarning — import it here to avoid circular deps
|
|
247
|
+
import chalk from "chalk";
|