ray-finance 0.3.7 โ 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -19
- package/dist/ai/agent.js +35 -29
- package/dist/ai/insights.js +19 -45
- package/dist/ai/model.d.ts +19 -0
- package/dist/ai/model.js +47 -0
- package/dist/ai/models-catalog.d.ts +33 -0
- package/dist/ai/models-catalog.js +58 -0
- package/dist/ai/provider.d.ts +58 -0
- package/dist/ai/provider.js +6 -0
- package/dist/ai/providers/anthropic.d.ts +5 -0
- package/dist/ai/providers/anthropic.js +47 -0
- package/dist/ai/providers/index.d.ts +2 -0
- package/dist/ai/providers/index.js +20 -0
- package/dist/ai/providers/openai-compat.d.ts +5 -0
- package/dist/ai/providers/openai-compat.js +142 -0
- package/dist/ai/system-prompt.js +23 -0
- package/dist/ai/tools.d.ts +2 -2
- package/dist/apple-import.d.ts +53 -0
- package/dist/apple-import.js +372 -0
- package/dist/cli/chat.js +92 -55
- package/dist/cli/commands.js +9 -24
- package/dist/cli/doctor.js +26 -0
- package/dist/cli/setup.js +219 -41
- package/dist/config.d.ts +3 -0
- package/dist/config.js +4 -1
- package/dist/currency.d.ts +6 -0
- package/dist/currency.js +88 -0
- package/dist/db/bills.d.ts +22 -0
- package/dist/db/bills.js +134 -0
- package/dist/providers/bridge/client.d.ts +85 -0
- package/dist/providers/bridge/client.js +132 -0
- package/dist/providers/bridge/index.d.ts +19 -0
- package/dist/providers/bridge/index.js +406 -0
- package/dist/providers/bridge/status.d.ts +24 -0
- package/dist/providers/bridge/status.js +30 -0
- package/dist/providers/index.d.ts +4 -0
- package/dist/providers/index.js +15 -0
- package/dist/providers/plaid.d.ts +2 -0
- package/dist/providers/plaid.js +94 -0
- package/dist/providers/state.d.ts +2 -0
- package/dist/providers/state.js +13 -0
- package/dist/providers/types.d.ts +30 -0
- package/dist/providers/types.js +1 -0
- package/dist/recategorization.d.ts +15 -0
- package/dist/recategorization.js +46 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ Tell Ray about your family, goals, and financial strategy once. From then on, ev
|
|
|
42
42
|
|
|
43
43
|
### Set it and forget it
|
|
44
44
|
|
|
45
|
-
- **Bank sync via Plaid** โ Connect checking, savings, credit cards, investments, and loans. Supports
|
|
45
|
+
- **Bank sync via Plaid** โ Connect checking, savings, credit cards, investments, and loans. Supports ๐บ๐ธ United States, ๐ฌ๐ง United Kingdom, and ๐จ๐ฆ Canada.
|
|
46
46
|
- **Scheduled daily sync** โ Automatic bank sync via launchd (macOS) or cron (Linux).
|
|
47
47
|
- **Auto-recategorization** โ Define rules to automatically re-label transactions.
|
|
48
48
|
- **Export/import** โ Back up and restore your financial data.
|
|
@@ -69,7 +69,7 @@ ray --demo alerts # financial alerts
|
|
|
69
69
|
ray --demo transactions # recent transactions
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
The dashboard commands work with no setup at all. To also try the AI chat with demo data, run `ray setup` first and add an
|
|
72
|
+
The dashboard commands work with no setup at all. To also try the AI chat with demo data, run `ray setup` first and add an API key (Anthropic, OpenAI, or any OpenAI-compatible provider) โ then `ray --demo` will start an interactive session where you can ask questions about the fake portfolio.
|
|
73
73
|
|
|
74
74
|
When you're ready to connect real accounts, run `ray link`.
|
|
75
75
|
|
|
@@ -92,12 +92,13 @@ We handle the API keys. Your data stays local. $10/mo.
|
|
|
92
92
|
|
|
93
93
|
### Bring your own keys
|
|
94
94
|
|
|
95
|
-
Bring your own
|
|
95
|
+
Bring your own AI and Plaid credentials. Free forever.
|
|
96
96
|
|
|
97
|
-
1.
|
|
98
|
-
2. Enter your
|
|
99
|
-
3.
|
|
100
|
-
4.
|
|
97
|
+
1. Pick your AI provider โ Anthropic, OpenAI, Ollama (local), or any OpenAI-compatible endpoint
|
|
98
|
+
2. Enter your API key and pick a model
|
|
99
|
+
3. Enter your Plaid credentials ([get free keys](https://dashboard.plaid.com/signup))
|
|
100
|
+
4. Link your accounts โ checking, savings, credit cards, investments, loans, mortgage
|
|
101
|
+
5. Done
|
|
101
102
|
|
|
102
103
|
## Commands
|
|
103
104
|
|
|
@@ -147,11 +148,11 @@ Run `ray --help` to see all available commands.
|
|
|
147
148
|
โ scoring ยท alerts โ
|
|
148
149
|
โโโโโโโโโโโโฌโโโโโโโโโโโ
|
|
149
150
|
โ
|
|
150
|
-
|
|
151
|
+
LLM API
|
|
151
152
|
(PII-masked)
|
|
152
153
|
```
|
|
153
154
|
|
|
154
|
-
Two outbound calls: Plaid (bank sync) and
|
|
155
|
+
Two outbound calls: Plaid (bank sync) and your AI provider (PII-masked). Supports Anthropic, OpenAI, Ollama, and any OpenAI-compatible endpoint. Your financial data is never stored off your machine. No telemetry. No analytics.
|
|
155
156
|
|
|
156
157
|
## Security & Privacy
|
|
157
158
|
|
|
@@ -159,8 +160,8 @@ Two outbound calls: Plaid (bank sync) and Anthropic (AI chat, PII-masked). Your
|
|
|
159
160
|
- Database encrypted with AES-256 (SQLCipher)
|
|
160
161
|
- Plaid access tokens encrypted at rest with AES-256-GCM
|
|
161
162
|
- Config file stored with `0600` permissions
|
|
162
|
-
- PII redacted before sending to
|
|
163
|
-
- No data leaves your machine โ only API calls to Plaid and
|
|
163
|
+
- PII redacted before sending to any AI provider
|
|
164
|
+
- No data leaves your machine โ only API calls to Plaid and your AI provider
|
|
164
165
|
|
|
165
166
|
## Configuration
|
|
166
167
|
|
|
@@ -181,18 +182,22 @@ Ray stores everything in `~/.ray/`:
|
|
|
181
182
|
You can also configure Ray via environment variables or a `.env` file:
|
|
182
183
|
|
|
183
184
|
```bash
|
|
184
|
-
ANTHROPIC_API_KEY=
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
185
|
+
ANTHROPIC_API_KEY= # Anthropic API key (if using Anthropic)
|
|
186
|
+
OPENAI_COMPATIBLE_KEY= # API key for OpenAI or compatible provider
|
|
187
|
+
OPENAI_COMPATIBLE_BASE_URL= # Base URL (e.g. https://api.openai.com/v1, http://localhost:11434/v1)
|
|
188
|
+
RAY_PROVIDER= # "anthropic" or "openai-compatible"
|
|
189
|
+
RAY_MODEL= # Model name (e.g. claude-sonnet-4-6, gpt-4o, llama3.1)
|
|
190
|
+
PLAID_CLIENT_ID= # Plaid client ID
|
|
191
|
+
PLAID_SECRET= # Plaid secret key
|
|
192
|
+
PLAID_ENV=production # Plaid environment
|
|
193
|
+
DB_ENCRYPTION_KEY= # Database encryption key
|
|
194
|
+
PLAID_TOKEN_SECRET= # Key for encrypting stored Plaid tokens
|
|
195
|
+
RAY_API_KEY= # Ray API key (managed mode, replaces the above)
|
|
191
196
|
```
|
|
192
197
|
|
|
193
198
|
## Roadmap
|
|
194
199
|
|
|
195
|
-
- [
|
|
200
|
+
- [x] Bring your own model โ use any LLM provider (OpenAI, Ollama, open-source models, etc.)
|
|
196
201
|
- [ ] Daily digest email โ morning summary of your finances
|
|
197
202
|
|
|
198
203
|
Have an idea? [Open a PR](https://github.com/cdinnison/ray-finance/pulls).
|
package/dist/ai/agent.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { config, useManaged, RAY_PROXY_BASE } from "../config.js";
|
|
1
|
+
import { config, useManaged } from "../config.js";
|
|
3
2
|
import { buildSystemPrompt } from "./system-prompt.js";
|
|
4
3
|
import { toolDefinitions, executeTool } from "./tools.js";
|
|
5
4
|
import { getConversationHistory, saveMessage } from "./memory.js";
|
|
6
5
|
import { logToolCall } from "./audit.js";
|
|
7
6
|
import { redact, unredact } from "./redactor.js";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
import { createProvider } from "./providers/index.js";
|
|
8
|
+
const provider = createProvider();
|
|
9
|
+
const MAX_TOOL_STEPS = 10;
|
|
11
10
|
function supportsThinking(model) {
|
|
12
11
|
return /sonnet-4|opus-4/i.test(model);
|
|
13
12
|
}
|
|
@@ -52,34 +51,29 @@ export async function handleMessage(db, userMessage, onProgress) {
|
|
|
52
51
|
if (messages.length === 0 || messages[messages.length - 1].content !== userMessage) {
|
|
53
52
|
messages.push({ role: "user", content: redact(userMessage) });
|
|
54
53
|
}
|
|
55
|
-
// Extended thinking config
|
|
56
|
-
const useThinking = config.thinkingBudget > 0
|
|
54
|
+
// Extended thinking config โ only for providers that support it
|
|
55
|
+
const useThinking = config.thinkingBudget > 0
|
|
56
|
+
&& provider.supportsThinking
|
|
57
|
+
&& supportsThinking(config.model);
|
|
57
58
|
try {
|
|
58
|
-
//
|
|
59
|
-
|
|
59
|
+
// Initial API call
|
|
60
|
+
let response = await provider.sendMessage({
|
|
60
61
|
model: config.model,
|
|
61
|
-
|
|
62
|
+
maxTokens: useThinking ? 16000 : 4096,
|
|
62
63
|
system: systemPrompt,
|
|
63
64
|
tools: toolDefinitions,
|
|
64
65
|
messages,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
budget_tokens: config.thinkingBudget,
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
// Initial API call
|
|
73
|
-
let response = await anthropic.messages.create(apiParams);
|
|
66
|
+
thinking: useThinking
|
|
67
|
+
? { type: "enabled", budget_tokens: config.thinkingBudget }
|
|
68
|
+
: undefined,
|
|
69
|
+
});
|
|
74
70
|
// Agentic tool loop
|
|
75
71
|
const startTime = Date.now();
|
|
76
72
|
let toolCount = 0;
|
|
77
|
-
while (response.
|
|
78
|
-
|
|
79
|
-
const assistantContent = response.content.filter((b) => b.type !== "thinking");
|
|
80
|
-
messages.push({ role: "assistant", content: assistantContent });
|
|
73
|
+
while (response.stopReason === "tool_use" && toolCount < MAX_TOOL_STEPS) {
|
|
74
|
+
messages.push({ role: "assistant", content: response.content });
|
|
81
75
|
const toolResults = [];
|
|
82
|
-
for (const block of
|
|
76
|
+
for (const block of response.content) {
|
|
83
77
|
if (block.type === "tool_use") {
|
|
84
78
|
toolCount++;
|
|
85
79
|
onProgress?.({
|
|
@@ -103,18 +97,30 @@ export async function handleMessage(db, userMessage, onProgress) {
|
|
|
103
97
|
toolCount,
|
|
104
98
|
elapsedMs: Date.now() - startTime,
|
|
105
99
|
});
|
|
106
|
-
response = await
|
|
100
|
+
response = await provider.sendMessage({
|
|
101
|
+
model: config.model,
|
|
102
|
+
maxTokens: useThinking ? 16000 : 4096,
|
|
103
|
+
system: systemPrompt,
|
|
104
|
+
tools: toolDefinitions,
|
|
105
|
+
messages,
|
|
106
|
+
thinking: useThinking
|
|
107
|
+
? { type: "enabled", budget_tokens: config.thinkingBudget }
|
|
108
|
+
: undefined,
|
|
109
|
+
});
|
|
107
110
|
}
|
|
108
|
-
// Extract text response
|
|
111
|
+
// Extract text response, restore PII for display
|
|
109
112
|
const textBlocks = response.content.filter((b) => b.type === "text");
|
|
110
|
-
const responseText = unredact(textBlocks.map(
|
|
113
|
+
const responseText = unredact(textBlocks.map(b => b.text).join("\n"));
|
|
111
114
|
// Save assistant response
|
|
112
115
|
saveMessage(db, "assistant", responseText);
|
|
113
116
|
return responseText || "I looked into that but couldn't formulate a response. Could you try rephrasing?";
|
|
114
117
|
}
|
|
115
118
|
catch (error) {
|
|
116
119
|
if (error.status === 403) {
|
|
117
|
-
|
|
120
|
+
if (useManaged()) {
|
|
121
|
+
return "Your API key was rejected. This usually means your subscription is inactive. Run `ray billing` to check your payment status, or `ray setup` to reconfigure.";
|
|
122
|
+
}
|
|
123
|
+
return "Your API key was rejected (403 Forbidden). Run `ray setup` to reconfigure your credentials.";
|
|
118
124
|
}
|
|
119
125
|
if (error.status === 401) {
|
|
120
126
|
return "Invalid API key. Run `ray setup` to reconfigure your credentials.";
|
|
@@ -123,7 +129,7 @@ export async function handleMessage(db, userMessage, onProgress) {
|
|
|
123
129
|
return "Rate limited. Wait a moment and try again.";
|
|
124
130
|
}
|
|
125
131
|
const safeMessage = error.status
|
|
126
|
-
? `API error (${error.status})`
|
|
132
|
+
? `API error (${error.status}): ${error.message || ""}`
|
|
127
133
|
: error.message || "internal error";
|
|
128
134
|
console.error("AI error:", safeMessage);
|
|
129
135
|
return "Sorry, I had trouble processing that. Could you try again?";
|
package/dist/ai/insights.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { getNetWorth, getAccountBalances, getDebts, getBudgetStatuses, getGoals, compareSpending, formatMoney, categoryLabel, } from "../queries/index.js";
|
|
3
3
|
import { getLatestScore } from "../scoring/index.js";
|
|
4
|
+
import { getUpcomingBills } from "../db/bills.js";
|
|
4
5
|
const MAX_CHARS = 6000;
|
|
5
6
|
export function computeInsights(db) {
|
|
6
7
|
// Fresh install guard
|
|
@@ -138,35 +139,14 @@ function buildGoals(db) {
|
|
|
138
139
|
}
|
|
139
140
|
function buildUpcoming(db) {
|
|
140
141
|
const parts = [];
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
const todayDay = now.getDate();
|
|
144
|
-
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
145
|
-
const endDay = todayDay + 7;
|
|
146
|
-
let bills = [];
|
|
147
|
-
if (endDay <= daysInMonth) {
|
|
148
|
-
bills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?`).all(todayDay + 1, endDay);
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
// Wraparound: rest of this month + start of next
|
|
152
|
-
const thisMonthBills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?`).all(todayDay + 1, daysInMonth);
|
|
153
|
-
const nextMonthBills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN 1 AND ?`).all(endDay - daysInMonth);
|
|
154
|
-
bills = [...thisMonthBills, ...nextMonthBills];
|
|
155
|
-
}
|
|
156
|
-
// Also handle bills on day 31 in shorter months
|
|
157
|
-
if (daysInMonth < 31) {
|
|
158
|
-
const endOfMonthBills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month > ? AND day_of_month NOT IN (SELECT day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?)`).all(daysInMonth, todayDay + 1, Math.min(endDay, daysInMonth));
|
|
159
|
-
// These bills fall on the last day of the month
|
|
160
|
-
if (daysInMonth >= todayDay + 1 && daysInMonth <= endDay) {
|
|
161
|
-
bills.push(...endOfMonthBills);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
142
|
+
const bills = getUpcomingBills(db, 7);
|
|
143
|
+
const today = startOfUtcDay(new Date());
|
|
164
144
|
if (bills.length > 0) {
|
|
165
145
|
const billStrs = bills.slice(0, 5).map(b => {
|
|
166
|
-
const daysUntil = b.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return `${b.name} (${
|
|
146
|
+
const daysUntil = Math.round((b.date.getTime() - today.getTime()) / 86400000);
|
|
147
|
+
const amt = formatMoney(b.amount);
|
|
148
|
+
const extra = b.note ? ` ${b.note}` : "";
|
|
149
|
+
return `${b.name} (${amt}${extra}) due in ${daysUntil} days`;
|
|
170
150
|
});
|
|
171
151
|
parts.push(`UPCOMING: ${billStrs.join(", ")}`);
|
|
172
152
|
}
|
|
@@ -245,7 +225,7 @@ export function cliBriefing(db) {
|
|
|
245
225
|
const change = nw.net_worth - nw.prev_net_worth;
|
|
246
226
|
nwLine += change >= 0
|
|
247
227
|
? chalk.green(` +${fmtMoney(change)}`)
|
|
248
|
-
: chalk.
|
|
228
|
+
: chalk.hex("#FF9F43")(` -${fmtMoney(Math.abs(change))}`);
|
|
249
229
|
}
|
|
250
230
|
lines.push(chalk.dim(" net worth") + nwLine);
|
|
251
231
|
// Account balances
|
|
@@ -272,7 +252,7 @@ export function cliBriefing(db) {
|
|
|
272
252
|
const cmp = compareSpending(db, lastMonthStart.toISOString().slice(0, 10), lastMonthSameDay.toISOString().slice(0, 10), monthStart.toISOString().slice(0, 10), today);
|
|
273
253
|
if (cmp.period1Total > 0) {
|
|
274
254
|
const diff = cmp.period2Total - cmp.period1Total;
|
|
275
|
-
const arrow = diff <= 0 ? chalk.green(`${fmtMoney(Math.abs(diff))} less`) : chalk.
|
|
255
|
+
const arrow = diff <= 0 ? chalk.green(`${fmtMoney(Math.abs(diff))} less`) : chalk.hex("#FF9F43")(`${fmtMoney(diff)} more`);
|
|
276
256
|
lines.push(chalk.dim(" spending") + chalk.white(` ${fmtMoney(thisMonthSpend.total)} this month`) + chalk.dim(` ยท `) + arrow + chalk.dim(` than this point last month`));
|
|
277
257
|
// Top movers (up to 3, show both ups and downs)
|
|
278
258
|
const movers = cmp.categories
|
|
@@ -282,7 +262,7 @@ export function cliBriefing(db) {
|
|
|
282
262
|
if (movers.length > 0) {
|
|
283
263
|
const moverStrs = movers.map(m => {
|
|
284
264
|
const label = categoryLabel(m.category).toLowerCase();
|
|
285
|
-
const color = m.diff <= 0 ? chalk.green : chalk.
|
|
265
|
+
const color = m.diff <= 0 ? chalk.green : chalk.hex("#FF9F43");
|
|
286
266
|
const sign = m.diff <= 0 ? "-" : "+";
|
|
287
267
|
return `${chalk.dim(label)} ${color(`${sign}${fmtMoney(Math.abs(m.diff))}`)}`;
|
|
288
268
|
});
|
|
@@ -300,7 +280,7 @@ export function cliBriefing(db) {
|
|
|
300
280
|
lines.push("");
|
|
301
281
|
for (const b of hot) {
|
|
302
282
|
const pct = Math.round(b.pct_used);
|
|
303
|
-
const color = b.over_budget ? chalk.
|
|
283
|
+
const color = b.over_budget ? chalk.hex("#FF9F43") : chalk.yellow;
|
|
304
284
|
const bar = miniBar(b.pct_used);
|
|
305
285
|
lines.push(` ${bar} ${color(categoryLabel(b.category).toLowerCase())} ${chalk.dim(`${pct}%`)}`);
|
|
306
286
|
}
|
|
@@ -318,21 +298,12 @@ export function cliBriefing(db) {
|
|
|
318
298
|
}
|
|
319
299
|
}
|
|
320
300
|
// Upcoming bills
|
|
321
|
-
const
|
|
322
|
-
const endDay = todayDay + 7;
|
|
323
|
-
let bills = [];
|
|
324
|
-
if (endDay <= daysInMonth) {
|
|
325
|
-
bills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?`).all(todayDay + 1, endDay);
|
|
326
|
-
}
|
|
327
|
-
else {
|
|
328
|
-
const a = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?`).all(todayDay + 1, daysInMonth);
|
|
329
|
-
const b = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN 1 AND ?`).all(endDay - daysInMonth);
|
|
330
|
-
bills = [...a, ...b];
|
|
331
|
-
}
|
|
301
|
+
const bills = getUpcomingBills(db, 7);
|
|
332
302
|
if (bills.length > 0) {
|
|
333
303
|
lines.push("");
|
|
304
|
+
const today = startOfUtcDay(new Date());
|
|
334
305
|
const billStrs = bills.slice(0, 3).map(b => {
|
|
335
|
-
const daysUntil =
|
|
306
|
+
const daysUntil = Math.round((b.date.getTime() - today.getTime()) / 86400000);
|
|
336
307
|
return chalk.dim(`${b.name} ${fmtMoney(b.amount)}`) + chalk.dim(` in ${daysUntil}d`);
|
|
337
308
|
});
|
|
338
309
|
lines.push(` ${chalk.dim("upcoming")} ${billStrs.join(chalk.dim(" ยท "))}`);
|
|
@@ -341,7 +312,7 @@ export function cliBriefing(db) {
|
|
|
341
312
|
const score = getLatestScore(db);
|
|
342
313
|
if (score) {
|
|
343
314
|
lines.push("");
|
|
344
|
-
const scoreColor = score.score >= 70 ? chalk.green : score.score >= 40 ? chalk.yellow : chalk.
|
|
315
|
+
const scoreColor = score.score >= 70 ? chalk.green : score.score >= 40 ? chalk.yellow : chalk.hex("#FF9F43");
|
|
345
316
|
let scoreLine = ` ${chalk.dim("score")} ${scoreColor(String(score.score))}${chalk.dim("/100")}`;
|
|
346
317
|
const streaks = [];
|
|
347
318
|
if (score.no_restaurant_streak > 0)
|
|
@@ -357,12 +328,15 @@ export function cliBriefing(db) {
|
|
|
357
328
|
function fmtMoney(n) {
|
|
358
329
|
return "$" + Math.abs(n).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
|
359
330
|
}
|
|
331
|
+
function startOfUtcDay(d) {
|
|
332
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
333
|
+
}
|
|
360
334
|
function miniBar(pct) {
|
|
361
335
|
const width = 8;
|
|
362
336
|
const clamped = Math.max(0, Math.min(100, pct));
|
|
363
337
|
const filled = Math.round((clamped / 100) * width);
|
|
364
338
|
const empty = width - filled;
|
|
365
|
-
const color = pct > 100 ? chalk.
|
|
339
|
+
const color = pct > 100 ? chalk.hex("#FF9F43") : pct > 80 ? chalk.yellow : chalk.green;
|
|
366
340
|
return color("โ".repeat(filled)) + chalk.dim("โ".repeat(empty));
|
|
367
341
|
}
|
|
368
342
|
function buildScore(db) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type RayConfig, type SelfHostedLlmProvider } from "../config.js";
|
|
2
|
+
export interface ResolvedSelfHostedModelConfig {
|
|
3
|
+
provider: SelfHostedLlmProvider;
|
|
4
|
+
providerLabel: string;
|
|
5
|
+
apiKey: string;
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
model: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function getResolvedSelfHostedModelConfig(input?: Partial<RayConfig>): ResolvedSelfHostedModelConfig;
|
|
10
|
+
export declare function supportsThinking(input?: Partial<RayConfig>): boolean;
|
|
11
|
+
export declare function createSelfHostedModel(input?: Partial<RayConfig>): any;
|
|
12
|
+
export declare function getSelfHostedProviderOptions(input?: Partial<RayConfig>): {
|
|
13
|
+
anthropic: {
|
|
14
|
+
thinking: {
|
|
15
|
+
type: "enabled";
|
|
16
|
+
budgetTokens: number;
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
} | undefined;
|
package/dist/ai/model.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
2
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
3
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
4
|
+
import { config, getLlmProviderLabel, resolveSelfHostedLlmConfig } from "../config.js";
|
|
5
|
+
export function getResolvedSelfHostedModelConfig(input = config) {
|
|
6
|
+
const resolved = resolveSelfHostedLlmConfig(input);
|
|
7
|
+
return {
|
|
8
|
+
...resolved,
|
|
9
|
+
providerLabel: getLlmProviderLabel(resolved.provider),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function supportsThinking(input = config) {
|
|
13
|
+
const { provider, model } = getResolvedSelfHostedModelConfig(input);
|
|
14
|
+
return provider === "anthropic" && /sonnet-4|opus-4/i.test(model);
|
|
15
|
+
}
|
|
16
|
+
export function createSelfHostedModel(input = config) {
|
|
17
|
+
const resolved = getResolvedSelfHostedModelConfig(input);
|
|
18
|
+
switch (resolved.provider) {
|
|
19
|
+
case "anthropic":
|
|
20
|
+
return createAnthropic({ apiKey: resolved.apiKey })(resolved.model);
|
|
21
|
+
case "openai":
|
|
22
|
+
return createOpenAI({ apiKey: resolved.apiKey })(resolved.model);
|
|
23
|
+
case "ollama":
|
|
24
|
+
return createOpenAICompatible({
|
|
25
|
+
name: "ollama",
|
|
26
|
+
apiKey: resolved.apiKey || "ollama",
|
|
27
|
+
baseURL: resolved.baseUrl,
|
|
28
|
+
})(resolved.model);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function getSelfHostedProviderOptions(input = config) {
|
|
32
|
+
const resolved = getResolvedSelfHostedModelConfig(input);
|
|
33
|
+
if (resolved.provider !== "anthropic" || !supportsThinking(input)) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const budgetTokens = input.thinkingBudget ?? 0;
|
|
37
|
+
if (budgetTokens <= 0)
|
|
38
|
+
return undefined;
|
|
39
|
+
return {
|
|
40
|
+
anthropic: {
|
|
41
|
+
thinking: {
|
|
42
|
+
type: "enabled",
|
|
43
|
+
budgetTokens,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface ModelEntry {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
family?: string;
|
|
5
|
+
tool_call?: boolean;
|
|
6
|
+
reasoning?: boolean;
|
|
7
|
+
cost?: {
|
|
8
|
+
input?: number;
|
|
9
|
+
output?: number;
|
|
10
|
+
};
|
|
11
|
+
limit?: {
|
|
12
|
+
context?: number;
|
|
13
|
+
output?: number;
|
|
14
|
+
};
|
|
15
|
+
release_date?: string;
|
|
16
|
+
modalities?: {
|
|
17
|
+
input?: string[];
|
|
18
|
+
output?: string[];
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
interface ProviderEntry {
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
api?: string;
|
|
25
|
+
models: Record<string, ModelEntry>;
|
|
26
|
+
}
|
|
27
|
+
type Catalog = Record<string, ProviderEntry>;
|
|
28
|
+
export declare function getCatalog(): Promise<Catalog>;
|
|
29
|
+
/** Get all models for a provider (e.g. "openai", "google", "mistral") */
|
|
30
|
+
export declare function getProviderModels(catalog: Catalog, providerId: string): ModelEntry[];
|
|
31
|
+
/** Look up a specific model by ID across all providers */
|
|
32
|
+
export declare function lookupModel(catalog: Catalog, modelId: string): ModelEntry | undefined;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
4
|
+
const CACHE_PATH = resolve(homedir(), ".ray", "models-cache.json");
|
|
5
|
+
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
6
|
+
const CATALOG_URL = "https://models.dev/api.json";
|
|
7
|
+
async function fetchCatalog() {
|
|
8
|
+
const resp = await fetch(CATALOG_URL);
|
|
9
|
+
if (!resp.ok)
|
|
10
|
+
throw new Error(`Failed to fetch model catalog: ${resp.status}`);
|
|
11
|
+
const data = await resp.json();
|
|
12
|
+
const dir = resolve(homedir(), ".ray");
|
|
13
|
+
if (!existsSync(dir))
|
|
14
|
+
mkdirSync(dir, { recursive: true });
|
|
15
|
+
const cached = { fetchedAt: Date.now(), data };
|
|
16
|
+
writeFileSync(CACHE_PATH, JSON.stringify(cached));
|
|
17
|
+
return data;
|
|
18
|
+
}
|
|
19
|
+
function getCachedCatalog() {
|
|
20
|
+
if (!existsSync(CACHE_PATH))
|
|
21
|
+
return null;
|
|
22
|
+
try {
|
|
23
|
+
const cached = JSON.parse(readFileSync(CACHE_PATH, "utf-8"));
|
|
24
|
+
if (Date.now() - cached.fetchedAt > CACHE_TTL)
|
|
25
|
+
return null;
|
|
26
|
+
return cached.data;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function getCatalog() {
|
|
33
|
+
const cached = getCachedCatalog();
|
|
34
|
+
if (cached)
|
|
35
|
+
return cached;
|
|
36
|
+
try {
|
|
37
|
+
return await fetchCatalog();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Get all models for a provider (e.g. "openai", "google", "mistral") */
|
|
44
|
+
export function getProviderModels(catalog, providerId) {
|
|
45
|
+
const provider = catalog[providerId];
|
|
46
|
+
if (!provider)
|
|
47
|
+
return [];
|
|
48
|
+
return Object.values(provider.models);
|
|
49
|
+
}
|
|
50
|
+
/** Look up a specific model by ID across all providers */
|
|
51
|
+
export function lookupModel(catalog, modelId) {
|
|
52
|
+
for (const provider of Object.values(catalog)) {
|
|
53
|
+
const model = provider.models[modelId];
|
|
54
|
+
if (model)
|
|
55
|
+
return model;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalized types for provider abstraction.
|
|
3
|
+
* Mirrors Anthropic's format (since that's our primary provider)
|
|
4
|
+
* but decoupled from the SDK types.
|
|
5
|
+
*/
|
|
6
|
+
export interface TextBlock {
|
|
7
|
+
type: "text";
|
|
8
|
+
text: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ToolUseBlock {
|
|
11
|
+
type: "tool_use";
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
input: any;
|
|
15
|
+
}
|
|
16
|
+
export type NormalizedContentBlock = TextBlock | ToolUseBlock;
|
|
17
|
+
export interface NormalizedResponse {
|
|
18
|
+
content: NormalizedContentBlock[];
|
|
19
|
+
stopReason: string;
|
|
20
|
+
usage?: {
|
|
21
|
+
input_tokens: number;
|
|
22
|
+
output_tokens: number;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export interface NormalizedMessage {
|
|
26
|
+
role: "user" | "assistant";
|
|
27
|
+
content: string | NormalizedContentBlock[] | NormalizedToolResult[];
|
|
28
|
+
}
|
|
29
|
+
export interface NormalizedToolResult {
|
|
30
|
+
type: "tool_result";
|
|
31
|
+
tool_use_id: string;
|
|
32
|
+
content: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ToolDefinition {
|
|
35
|
+
name: string;
|
|
36
|
+
description: string;
|
|
37
|
+
input_schema: {
|
|
38
|
+
type: "object";
|
|
39
|
+
properties: Record<string, any>;
|
|
40
|
+
required: string[];
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export interface SendMessageParams {
|
|
44
|
+
model: string;
|
|
45
|
+
system: string;
|
|
46
|
+
messages: NormalizedMessage[];
|
|
47
|
+
tools: ToolDefinition[];
|
|
48
|
+
maxTokens: number;
|
|
49
|
+
thinking?: {
|
|
50
|
+
type: "enabled";
|
|
51
|
+
budget_tokens: number;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export interface Provider {
|
|
55
|
+
name: string;
|
|
56
|
+
supportsThinking: boolean;
|
|
57
|
+
sendMessage(params: SendMessageParams): Promise<NormalizedResponse>;
|
|
58
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
export function createAnthropicProvider(opts) {
|
|
3
|
+
const client = new Anthropic(opts.baseURL
|
|
4
|
+
? { apiKey: opts.apiKey, baseURL: opts.baseURL }
|
|
5
|
+
: { apiKey: opts.apiKey });
|
|
6
|
+
return {
|
|
7
|
+
name: "anthropic",
|
|
8
|
+
supportsThinking: true,
|
|
9
|
+
async sendMessage(params) {
|
|
10
|
+
const apiParams = {
|
|
11
|
+
model: params.model,
|
|
12
|
+
max_tokens: params.maxTokens,
|
|
13
|
+
system: params.system,
|
|
14
|
+
tools: params.tools,
|
|
15
|
+
messages: params.messages,
|
|
16
|
+
};
|
|
17
|
+
if (params.thinking) {
|
|
18
|
+
apiParams.thinking = params.thinking;
|
|
19
|
+
}
|
|
20
|
+
const response = await client.messages.create(apiParams);
|
|
21
|
+
// Filter thinking blocks and normalize content
|
|
22
|
+
const content = [];
|
|
23
|
+
for (const block of response.content) {
|
|
24
|
+
if (block.type === "thinking")
|
|
25
|
+
continue;
|
|
26
|
+
if (block.type === "text") {
|
|
27
|
+
content.push({ type: "text", text: block.text });
|
|
28
|
+
}
|
|
29
|
+
else if (block.type === "tool_use") {
|
|
30
|
+
content.push({
|
|
31
|
+
type: "tool_use",
|
|
32
|
+
id: block.id,
|
|
33
|
+
name: block.name,
|
|
34
|
+
input: block.input,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
content,
|
|
40
|
+
stopReason: response.stop_reason || "end_turn",
|
|
41
|
+
usage: response.usage
|
|
42
|
+
? { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens }
|
|
43
|
+
: undefined,
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { config, useManaged, RAY_PROXY_BASE } from "../../config.js";
|
|
2
|
+
import { createAnthropicProvider } from "./anthropic.js";
|
|
3
|
+
import { createOpenAICompatibleProvider } from "./openai-compat.js";
|
|
4
|
+
export function createProvider() {
|
|
5
|
+
if (useManaged()) {
|
|
6
|
+
return createAnthropicProvider({
|
|
7
|
+
apiKey: config.rayApiKey,
|
|
8
|
+
baseURL: `${RAY_PROXY_BASE}/ai`,
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
if (config.providerType === "openai-compatible") {
|
|
12
|
+
return createOpenAICompatibleProvider({
|
|
13
|
+
apiKey: config.openaiCompatibleKey,
|
|
14
|
+
baseURL: config.openaiCompatibleBaseURL,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return createAnthropicProvider({
|
|
18
|
+
apiKey: config.anthropicKey,
|
|
19
|
+
});
|
|
20
|
+
}
|