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.
Files changed (38) hide show
  1. package/README.md +24 -19
  2. package/dist/ai/agent.js +35 -29
  3. package/dist/ai/insights.js +6 -6
  4. package/dist/ai/model.d.ts +19 -0
  5. package/dist/ai/model.js +47 -0
  6. package/dist/ai/models-catalog.d.ts +33 -0
  7. package/dist/ai/models-catalog.js +58 -0
  8. package/dist/ai/provider.d.ts +58 -0
  9. package/dist/ai/provider.js +6 -0
  10. package/dist/ai/providers/anthropic.d.ts +5 -0
  11. package/dist/ai/providers/anthropic.js +47 -0
  12. package/dist/ai/providers/index.d.ts +2 -0
  13. package/dist/ai/providers/index.js +20 -0
  14. package/dist/ai/providers/openai-compat.d.ts +5 -0
  15. package/dist/ai/providers/openai-compat.js +142 -0
  16. package/dist/ai/system-prompt.js +23 -0
  17. package/dist/ai/tools.d.ts +2 -2
  18. package/dist/cli/doctor.js +26 -0
  19. package/dist/cli/setup.js +219 -41
  20. package/dist/config.d.ts +3 -0
  21. package/dist/config.js +4 -1
  22. package/dist/currency.d.ts +6 -0
  23. package/dist/currency.js +88 -0
  24. package/dist/providers/bridge/client.d.ts +85 -0
  25. package/dist/providers/bridge/client.js +132 -0
  26. package/dist/providers/bridge/index.d.ts +19 -0
  27. package/dist/providers/bridge/index.js +406 -0
  28. package/dist/providers/bridge/status.d.ts +24 -0
  29. package/dist/providers/bridge/status.js +30 -0
  30. package/dist/providers/index.d.ts +4 -0
  31. package/dist/providers/index.js +15 -0
  32. package/dist/providers/plaid.d.ts +2 -0
  33. package/dist/providers/plaid.js +94 -0
  34. package/dist/providers/state.d.ts +2 -0
  35. package/dist/providers/state.js +13 -0
  36. package/dist/providers/types.d.ts +30 -0
  37. package/dist/providers/types.js +1 -0
  38. 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 18 countries: ๐Ÿ‡บ๐Ÿ‡ธ United States, ๐Ÿ‡ฌ๐Ÿ‡ง United Kingdom, ๐Ÿ‡จ๐Ÿ‡ฆ Canada, ๐Ÿ‡ซ๐Ÿ‡ท France, ๐Ÿ‡ฉ๐Ÿ‡ช Germany, ๐Ÿ‡ช๐Ÿ‡ธ Spain, ๐Ÿ‡ฎ๐Ÿ‡น Italy, ๐Ÿ‡ณ๐Ÿ‡ฑ Netherlands, ๐Ÿ‡ฎ๐Ÿ‡ช Ireland, ๐Ÿ‡ต๐Ÿ‡ฑ Poland, ๐Ÿ‡ฉ๐Ÿ‡ฐ Denmark, ๐Ÿ‡ณ๐Ÿ‡ด Norway, ๐Ÿ‡ธ๐Ÿ‡ช Sweden, ๐Ÿ‡ช๐Ÿ‡ช Estonia, ๐Ÿ‡ฑ๐Ÿ‡น Lithuania, ๐Ÿ‡ฑ๐Ÿ‡ป Latvia, ๐Ÿ‡ต๐Ÿ‡น Portugal, and ๐Ÿ‡ง๐Ÿ‡ช Belgium.
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 [Anthropic API key](https://console.anthropic.com) or Ray API key โ€” then `ray --demo` will start an interactive session where you can ask questions about the fake portfolio.
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 Anthropic and Plaid credentials. Free forever.
95
+ Bring your own AI and Plaid credentials. Free forever.
96
96
 
97
- 1. Enter your Anthropic API key ([get one](https://console.anthropic.com))
98
- 2. Enter your Plaid credentials ([get free keys](https://dashboard.plaid.com/signup))
99
- 3. Link your accounts โ€” checking, savings, credit cards, investments, loans, mortgage
100
- 4. Done
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
- Claude API
151
+ LLM API
151
152
  (PII-masked)
152
153
  ```
153
154
 
154
- Two outbound calls: Plaid (bank sync) and Anthropic (AI chat, PII-masked). Your financial data is never stored off your machine. No telemetry. No analytics.
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 Claude API
163
- - No data leaves your machine โ€” only API calls to Plaid and Anthropic
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= # Anthropic API key for AI chat
185
- PLAID_CLIENT_ID= # Plaid client ID
186
- PLAID_SECRET= # Plaid secret key
187
- PLAID_ENV=production # Plaid environment
188
- DB_ENCRYPTION_KEY= # Database encryption key
189
- PLAID_TOKEN_SECRET= # Key for encrypting stored Plaid tokens
190
- RAY_API_KEY= # Ray API key (managed mode, replaces the above)
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
- - [ ] Bring your own model โ€” use any LLM provider (OpenAI, Ollama, open-source models, etc.)
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 Anthropic from "@anthropic-ai/sdk";
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
- const anthropic = new Anthropic(useManaged()
9
- ? { apiKey: config.rayApiKey, baseURL: `${RAY_PROXY_BASE}/ai` }
10
- : { apiKey: config.anthropicKey });
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 && supportsThinking(config.model);
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
- // Build API params
59
- const apiParams = {
59
+ // Initial API call
60
+ let response = await provider.sendMessage({
60
61
  model: config.model,
61
- max_tokens: useThinking ? 16000 : 4096,
62
+ maxTokens: useThinking ? 16000 : 4096,
62
63
  system: systemPrompt,
63
64
  tools: toolDefinitions,
64
65
  messages,
65
- };
66
- if (useThinking) {
67
- apiParams.thinking = {
68
- type: "enabled",
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.stop_reason === "tool_use") {
78
- // Filter out thinking blocks before adding to messages
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 assistantContent) {
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 anthropic.messages.create(apiParams);
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 (filter out thinking blocks), restore PII for display
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((b) => b.text).join("\n"));
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
- 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.";
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?";
@@ -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.red(` -${fmtMoney(Math.abs(change))}`);
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.red(`${fmtMoney(diff)} more`);
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.red;
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.red : chalk.yellow;
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.red;
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.red : pct > 80 ? chalk.yellow : chalk.green;
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;
@@ -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,6 @@
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 {};
@@ -0,0 +1,5 @@
1
+ import type { Provider } from "../provider.js";
2
+ export declare function createAnthropicProvider(opts: {
3
+ apiKey: string;
4
+ baseURL?: string;
5
+ }): Provider;
@@ -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,2 @@
1
+ import type { Provider } from "../provider.js";
2
+ export declare function createProvider(): Provider;
@@ -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
+ }
@@ -0,0 +1,5 @@
1
+ import type { Provider } from "../provider.js";
2
+ export declare function createOpenAICompatibleProvider(opts: {
3
+ apiKey: string;
4
+ baseURL: string;
5
+ }): Provider;