ray-finance 0.3.7 โ 0.4.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/README.md +24 -19
- package/dist/ai/agent.js +35 -29
- package/dist/ai/insights.js +6 -6
- 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/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/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/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
|
@@ -245,7 +245,7 @@ export function cliBriefing(db) {
|
|
|
245
245
|
const change = nw.net_worth - nw.prev_net_worth;
|
|
246
246
|
nwLine += change >= 0
|
|
247
247
|
? chalk.green(` +${fmtMoney(change)}`)
|
|
248
|
-
: chalk.
|
|
248
|
+
: chalk.hex("#FF9F43")(` -${fmtMoney(Math.abs(change))}`);
|
|
249
249
|
}
|
|
250
250
|
lines.push(chalk.dim(" net worth") + nwLine);
|
|
251
251
|
// Account balances
|
|
@@ -272,7 +272,7 @@ export function cliBriefing(db) {
|
|
|
272
272
|
const cmp = compareSpending(db, lastMonthStart.toISOString().slice(0, 10), lastMonthSameDay.toISOString().slice(0, 10), monthStart.toISOString().slice(0, 10), today);
|
|
273
273
|
if (cmp.period1Total > 0) {
|
|
274
274
|
const diff = cmp.period2Total - cmp.period1Total;
|
|
275
|
-
const arrow = diff <= 0 ? chalk.green(`${fmtMoney(Math.abs(diff))} less`) : chalk.
|
|
275
|
+
const arrow = diff <= 0 ? chalk.green(`${fmtMoney(Math.abs(diff))} less`) : chalk.hex("#FF9F43")(`${fmtMoney(diff)} more`);
|
|
276
276
|
lines.push(chalk.dim(" spending") + chalk.white(` ${fmtMoney(thisMonthSpend.total)} this month`) + chalk.dim(` ยท `) + arrow + chalk.dim(` than this point last month`));
|
|
277
277
|
// Top movers (up to 3, show both ups and downs)
|
|
278
278
|
const movers = cmp.categories
|
|
@@ -282,7 +282,7 @@ export function cliBriefing(db) {
|
|
|
282
282
|
if (movers.length > 0) {
|
|
283
283
|
const moverStrs = movers.map(m => {
|
|
284
284
|
const label = categoryLabel(m.category).toLowerCase();
|
|
285
|
-
const color = m.diff <= 0 ? chalk.green : chalk.
|
|
285
|
+
const color = m.diff <= 0 ? chalk.green : chalk.hex("#FF9F43");
|
|
286
286
|
const sign = m.diff <= 0 ? "-" : "+";
|
|
287
287
|
return `${chalk.dim(label)} ${color(`${sign}${fmtMoney(Math.abs(m.diff))}`)}`;
|
|
288
288
|
});
|
|
@@ -300,7 +300,7 @@ export function cliBriefing(db) {
|
|
|
300
300
|
lines.push("");
|
|
301
301
|
for (const b of hot) {
|
|
302
302
|
const pct = Math.round(b.pct_used);
|
|
303
|
-
const color = b.over_budget ? chalk.
|
|
303
|
+
const color = b.over_budget ? chalk.hex("#FF9F43") : chalk.yellow;
|
|
304
304
|
const bar = miniBar(b.pct_used);
|
|
305
305
|
lines.push(` ${bar} ${color(categoryLabel(b.category).toLowerCase())} ${chalk.dim(`${pct}%`)}`);
|
|
306
306
|
}
|
|
@@ -341,7 +341,7 @@ export function cliBriefing(db) {
|
|
|
341
341
|
const score = getLatestScore(db);
|
|
342
342
|
if (score) {
|
|
343
343
|
lines.push("");
|
|
344
|
-
const scoreColor = score.score >= 70 ? chalk.green : score.score >= 40 ? chalk.yellow : chalk.
|
|
344
|
+
const scoreColor = score.score >= 70 ? chalk.green : score.score >= 40 ? chalk.yellow : chalk.hex("#FF9F43");
|
|
345
345
|
let scoreLine = ` ${chalk.dim("score")} ${scoreColor(String(score.score))}${chalk.dim("/100")}`;
|
|
346
346
|
const streaks = [];
|
|
347
347
|
if (score.no_restaurant_streak > 0)
|
|
@@ -362,7 +362,7 @@ function miniBar(pct) {
|
|
|
362
362
|
const clamped = Math.max(0, Math.min(100, pct));
|
|
363
363
|
const filled = Math.round((clamped / 100) * width);
|
|
364
364
|
const empty = width - filled;
|
|
365
|
-
const color = pct > 100 ? chalk.
|
|
365
|
+
const color = pct > 100 ? chalk.hex("#FF9F43") : pct > 80 ? chalk.yellow : chalk.green;
|
|
366
366
|
return color("โ".repeat(filled)) + chalk.dim("โ".repeat(empty));
|
|
367
367
|
}
|
|
368
368
|
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
|
+
}
|