@makefinks/daemon 0.1.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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -0
  3. package/dist/cli.js +22 -0
  4. package/package.json +79 -0
  5. package/src/ai/agent-turn-runner.ts +130 -0
  6. package/src/ai/daemon-ai.ts +403 -0
  7. package/src/ai/exa-client.ts +21 -0
  8. package/src/ai/exa-fetch-cache.ts +104 -0
  9. package/src/ai/model-config.ts +99 -0
  10. package/src/ai/sanitize-messages.ts +83 -0
  11. package/src/ai/system-prompt.ts +363 -0
  12. package/src/ai/tools/fetch-urls.ts +187 -0
  13. package/src/ai/tools/grounding-manager.ts +94 -0
  14. package/src/ai/tools/index.ts +52 -0
  15. package/src/ai/tools/read-file.ts +100 -0
  16. package/src/ai/tools/render-url.ts +275 -0
  17. package/src/ai/tools/run-bash.ts +224 -0
  18. package/src/ai/tools/subagents.ts +195 -0
  19. package/src/ai/tools/todo-manager.ts +150 -0
  20. package/src/ai/tools/web-search.ts +91 -0
  21. package/src/app/App.tsx +711 -0
  22. package/src/app/components/AppOverlays.tsx +131 -0
  23. package/src/app/components/AvatarLayer.tsx +51 -0
  24. package/src/app/components/ConversationPane.tsx +476 -0
  25. package/src/avatar/DaemonAvatarRenderable.ts +343 -0
  26. package/src/avatar/daemon-avatar-rig.ts +1165 -0
  27. package/src/avatar-preview.ts +186 -0
  28. package/src/cli.ts +26 -0
  29. package/src/components/ApiKeyInput.tsx +99 -0
  30. package/src/components/ApiKeyStep.tsx +95 -0
  31. package/src/components/ApprovalPicker.tsx +109 -0
  32. package/src/components/ContentBlockView.tsx +141 -0
  33. package/src/components/DaemonText.tsx +34 -0
  34. package/src/components/DeviceMenu.tsx +166 -0
  35. package/src/components/GroundingBadge.tsx +21 -0
  36. package/src/components/GroundingMenu.tsx +310 -0
  37. package/src/components/HotkeysPane.tsx +115 -0
  38. package/src/components/InlineStatusIndicator.tsx +106 -0
  39. package/src/components/ModelMenu.tsx +411 -0
  40. package/src/components/OnboardingOverlay.tsx +446 -0
  41. package/src/components/ProviderMenu.tsx +177 -0
  42. package/src/components/SessionMenu.tsx +297 -0
  43. package/src/components/SettingsMenu.tsx +291 -0
  44. package/src/components/StatusBar.tsx +126 -0
  45. package/src/components/TokenUsageDisplay.tsx +92 -0
  46. package/src/components/ToolCallView.tsx +113 -0
  47. package/src/components/TypingInputBar.tsx +131 -0
  48. package/src/components/tool-layouts/components.tsx +120 -0
  49. package/src/components/tool-layouts/defaults.ts +9 -0
  50. package/src/components/tool-layouts/index.ts +22 -0
  51. package/src/components/tool-layouts/layouts/bash.ts +110 -0
  52. package/src/components/tool-layouts/layouts/grounding.tsx +98 -0
  53. package/src/components/tool-layouts/layouts/index.ts +8 -0
  54. package/src/components/tool-layouts/layouts/read-file.ts +59 -0
  55. package/src/components/tool-layouts/layouts/subagent.tsx +118 -0
  56. package/src/components/tool-layouts/layouts/system-info.ts +8 -0
  57. package/src/components/tool-layouts/layouts/todo.tsx +139 -0
  58. package/src/components/tool-layouts/layouts/url-tools.ts +220 -0
  59. package/src/components/tool-layouts/layouts/web-search.ts +110 -0
  60. package/src/components/tool-layouts/registry.ts +17 -0
  61. package/src/components/tool-layouts/types.ts +94 -0
  62. package/src/hooks/daemon-event-handlers.ts +944 -0
  63. package/src/hooks/keyboard-handlers.ts +399 -0
  64. package/src/hooks/menu-navigation.ts +147 -0
  65. package/src/hooks/use-app-audio-devices-loader.ts +71 -0
  66. package/src/hooks/use-app-callbacks.ts +202 -0
  67. package/src/hooks/use-app-context-builder.ts +159 -0
  68. package/src/hooks/use-app-display-state.ts +162 -0
  69. package/src/hooks/use-app-menus.ts +51 -0
  70. package/src/hooks/use-app-model-pricing-loader.ts +45 -0
  71. package/src/hooks/use-app-model.ts +123 -0
  72. package/src/hooks/use-app-openrouter-models-loader.ts +44 -0
  73. package/src/hooks/use-app-openrouter-provider-loader.ts +35 -0
  74. package/src/hooks/use-app-preferences-bootstrap.ts +212 -0
  75. package/src/hooks/use-app-sessions.ts +105 -0
  76. package/src/hooks/use-app-settings.ts +62 -0
  77. package/src/hooks/use-conversation-manager.ts +163 -0
  78. package/src/hooks/use-copy-on-select.ts +50 -0
  79. package/src/hooks/use-daemon-events.ts +396 -0
  80. package/src/hooks/use-daemon-keyboard.ts +397 -0
  81. package/src/hooks/use-grounding.ts +46 -0
  82. package/src/hooks/use-input-history.ts +92 -0
  83. package/src/hooks/use-menu-keyboard.ts +93 -0
  84. package/src/hooks/use-playwright-notification.ts +23 -0
  85. package/src/hooks/use-reasoning-animation.ts +97 -0
  86. package/src/hooks/use-response-timer.ts +55 -0
  87. package/src/hooks/use-tool-approval.tsx +202 -0
  88. package/src/hooks/use-typing-mode.ts +137 -0
  89. package/src/hooks/use-voice-dependencies-notification.ts +37 -0
  90. package/src/index.tsx +48 -0
  91. package/src/scripts/setup-browsers.ts +42 -0
  92. package/src/state/app-context.tsx +160 -0
  93. package/src/state/daemon-events.ts +67 -0
  94. package/src/state/daemon-state.ts +493 -0
  95. package/src/state/migrations/001-init.ts +33 -0
  96. package/src/state/migrations/index.ts +8 -0
  97. package/src/state/model-history-store.ts +45 -0
  98. package/src/state/runtime-context.ts +21 -0
  99. package/src/state/session-store.ts +359 -0
  100. package/src/types/index.ts +405 -0
  101. package/src/types/theme.ts +52 -0
  102. package/src/ui/constants.ts +157 -0
  103. package/src/utils/clipboard.ts +89 -0
  104. package/src/utils/debug-logger.ts +69 -0
  105. package/src/utils/formatters.ts +242 -0
  106. package/src/utils/js-rendering.ts +77 -0
  107. package/src/utils/markdown-tables.ts +234 -0
  108. package/src/utils/model-metadata.ts +191 -0
  109. package/src/utils/openrouter-endpoints.ts +212 -0
  110. package/src/utils/openrouter-models.ts +205 -0
  111. package/src/utils/openrouter-pricing.ts +59 -0
  112. package/src/utils/openrouter-reported-cost.ts +16 -0
  113. package/src/utils/paste.ts +33 -0
  114. package/src/utils/preferences.ts +289 -0
  115. package/src/utils/text-fragment.ts +39 -0
  116. package/src/utils/tool-output-preview.ts +250 -0
  117. package/src/utils/voice-dependencies.ts +107 -0
  118. package/src/utils/workspace-manager.ts +85 -0
  119. package/src/voice/audio-recorder.ts +579 -0
  120. package/src/voice/mic-level.ts +35 -0
  121. package/src/voice/tts/openai-tts-stream.ts +222 -0
  122. package/src/voice/tts/speech-controller.ts +64 -0
  123. package/src/voice/tts/tts-player.ts +257 -0
  124. package/src/voice/voice-input-controller.ts +96 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Fetches and caches model metadata from OpenRouter API.
3
+ * Provides context window size and pricing information.
4
+ */
5
+
6
+ import type { ModelPricing } from "../types";
7
+ import { debug } from "./debug-logger";
8
+ import { getOpenRouterModelEndpointsMetadata } from "./openrouter-endpoints";
9
+ import { mergePricingAverages } from "./openrouter-pricing";
10
+
11
+ export interface ModelMetadata {
12
+ id: string;
13
+ name: string;
14
+ contextLength: number;
15
+ pricing?: ModelPricing;
16
+ /** Whether this model supports the reasoning effort parameter */
17
+ supportsReasoning: boolean;
18
+ /** Whether any provider endpoint supports caching (best-effort). */
19
+ supportsCaching: boolean;
20
+ }
21
+
22
+ type CachedModelMetadataEntry = {
23
+ timestamp: number;
24
+ metadata: ModelMetadata;
25
+ };
26
+
27
+ // In-memory cache for model metadata (derived from per-model endpoints)
28
+ let cachedByModelId: Map<string, CachedModelMetadataEntry> | null = null;
29
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
30
+
31
+ /**
32
+ * Build model-level metadata from OpenRouter per-model endpoint info.
33
+ */
34
+ async function fetchModelMetadata(modelId: string): Promise<ModelMetadata | null> {
35
+ const now = Date.now();
36
+
37
+ if (!cachedByModelId) cachedByModelId = new Map();
38
+
39
+ const cached = cachedByModelId.get(modelId);
40
+ if (cached && now - cached.timestamp < CACHE_TTL_MS) return cached.metadata;
41
+
42
+ try {
43
+ debug.log("Fetching model endpoint metadata from OpenRouter...", { modelId });
44
+
45
+ const endpoints = await getOpenRouterModelEndpointsMetadata(modelId);
46
+ if (!endpoints) return null;
47
+
48
+ const contextLength = endpoints.providers.reduce((max, p) => {
49
+ const value = typeof p.contextLength === "number" ? p.contextLength : 0;
50
+ return Math.max(max, value);
51
+ }, 0);
52
+
53
+ const pricingCandidates = endpoints.providers
54
+ .map((p) => p.pricing)
55
+ .filter((p): p is ModelPricing => Boolean(p));
56
+
57
+ const pricing = pricingCandidates.length > 0 ? mergePricingAverages(pricingCandidates) : undefined;
58
+
59
+ const supportsCaching = endpoints.providers.some((p) => p.supportsCaching);
60
+
61
+ const metadata: ModelMetadata = {
62
+ id: endpoints.modelId,
63
+ name: endpoints.modelName ?? endpoints.modelId,
64
+ contextLength,
65
+ pricing,
66
+ supportsReasoning: endpoints.supportsReasoning,
67
+ supportsCaching,
68
+ };
69
+
70
+ cachedByModelId.set(modelId, { timestamp: now, metadata });
71
+ return metadata;
72
+ } catch (error) {
73
+ debug.error("Failed to fetch model metadata:", error);
74
+ return cached?.metadata ?? null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Get metadata for a specific model.
80
+ * @param modelId - The OpenRouter model ID (e.g., "google/gemini-2.5-flash-preview")
81
+ */
82
+ export async function getModelMetadata(modelId: string): Promise<ModelMetadata | null> {
83
+ return fetchModelMetadata(modelId);
84
+ }
85
+
86
+ /**
87
+ * Get metadata for multiple models.
88
+ * @param modelIds - Array of OpenRouter model IDs
89
+ * @returns Map of model ID to metadata (only includes models that were found)
90
+ */
91
+ export async function getModelsMetadata(modelIds: string[]): Promise<Map<string, ModelMetadata>> {
92
+ const result = new Map<string, ModelMetadata>();
93
+ const concurrency = 4;
94
+ let index = 0;
95
+
96
+ const workers = Array.from({ length: Math.min(concurrency, modelIds.length) }).map(async () => {
97
+ while (index < modelIds.length) {
98
+ const current = modelIds[index++];
99
+ if (!current) continue;
100
+ const metadata = await fetchModelMetadata(current);
101
+ if (metadata) result.set(current, metadata);
102
+ }
103
+ });
104
+
105
+ await Promise.all(workers);
106
+ return result;
107
+ }
108
+
109
+ export async function getOpenRouterProviderPricing(
110
+ modelId: string,
111
+ providerTag: string
112
+ ): Promise<ModelPricing | undefined> {
113
+ const endpoints = await getOpenRouterModelEndpointsMetadata(modelId);
114
+ return resolveOpenRouterProviderPricing(endpoints?.providers ?? [], providerTag);
115
+ }
116
+
117
+ export function resolveOpenRouterProviderPricing(
118
+ providers: Array<{ tag: string; providerName: string; pricing?: ModelPricing }>,
119
+ providerId: string
120
+ ): ModelPricing | undefined {
121
+ const normalized = providerId.trim().toLowerCase();
122
+ if (!normalized) return undefined;
123
+
124
+ const provider = providers.find((p) => {
125
+ return p.tag.toLowerCase() === normalized || p.providerName.trim().toLowerCase() === normalized;
126
+ });
127
+
128
+ return provider?.pricing;
129
+ }
130
+
131
+ export async function calculateOpenRouterCostForProvider(
132
+ modelId: string,
133
+ providerTag: string,
134
+ promptTokens: number,
135
+ completionTokens: number,
136
+ cachedInputTokens?: number
137
+ ): Promise<number | undefined> {
138
+ const pricing = await getOpenRouterProviderPricing(modelId, providerTag);
139
+ if (!pricing) return undefined;
140
+ return calculateCost(promptTokens, completionTokens, pricing, cachedInputTokens);
141
+ }
142
+
143
+ /**
144
+ * Calculate the cost in USD for a given token usage.
145
+ *
146
+ * Note: some providers expose discounted cache pricing (OpenRouter: `input_cache_read`).
147
+ * When `cachedInputTokens` is provided and the model has `pricing.inputCacheRead`,
148
+ * those cached tokens are charged at the discounted rate.
149
+ */
150
+ export function calculateCost(
151
+ promptTokens: number,
152
+ completionTokens: number,
153
+ pricing: ModelPricing,
154
+ cachedInputTokens?: number
155
+ ): number {
156
+ const cached = Math.max(0, Math.min(cachedInputTokens ?? 0, promptTokens));
157
+ const uncachedPromptTokens = Math.max(0, promptTokens - cached);
158
+
159
+ const promptCost =
160
+ (uncachedPromptTokens / 1_000_000) * pricing.prompt +
161
+ (cached / 1_000_000) * (pricing.inputCacheRead ?? pricing.prompt);
162
+ const completionCost = (completionTokens / 1_000_000) * pricing.completion;
163
+ return promptCost + completionCost;
164
+ }
165
+
166
+ /**
167
+ * Format a cost value as a USD string.
168
+ */
169
+ export function formatCost(cost: number): string {
170
+ if (cost <= 0) {
171
+ return "$0.00";
172
+ }
173
+ if (cost < 0.0001) {
174
+ return "<$0.0001";
175
+ }
176
+ if (cost < 0.01) {
177
+ return `$${cost.toFixed(4)}`;
178
+ }
179
+ return `$${cost.toFixed(2)}`;
180
+ }
181
+
182
+ /**
183
+ * Format context usage as a percentage string.
184
+ */
185
+ export function formatContextUsage(usedTokens: number, contextLength: number): string {
186
+ const percentage = (usedTokens / contextLength) * 100;
187
+ if (percentage < 1) {
188
+ return `${percentage.toFixed(1)}%`;
189
+ }
190
+ return `${Math.round(percentage)}%`;
191
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Fetches and caches per-model endpoint/provider data from OpenRouter.
3
+ *
4
+ * Note: OpenRouter's `/api/v1/models` endpoint does not include the full list of
5
+ * inference providers. The provider list is exposed per-model via:
6
+ * `/api/v1/models/{modelId}/endpoints`
7
+ */
8
+
9
+ import { debug } from "./debug-logger";
10
+ import type { ModelPricing } from "../types";
11
+ import { mergePricingAverages, parseOpenRouterPricePerTokenToPerMillion } from "./openrouter-pricing";
12
+
13
+ export interface OpenRouterInferenceProvider {
14
+ /** Provider routing slug (matches OpenRouter `provider.order` entries), e.g. "openai" */
15
+ tag: string;
16
+ /** Human-friendly provider name, e.g. "OpenAI" */
17
+ providerName: string;
18
+ /** Context length for this provider (best-effort, max across endpoints) */
19
+ contextLength?: number;
20
+ /** Prices per 1M tokens (best-effort, averaged across endpoints) */
21
+ pricing?: ModelPricing;
22
+ /** Whether this provider appears to support caching */
23
+ supportsCaching: boolean;
24
+ }
25
+
26
+ interface OpenRouterEndpointsEndpoint {
27
+ provider_name?: string;
28
+ tag?: string;
29
+ context_length?: number;
30
+ pricing?: {
31
+ prompt?: string;
32
+ completion?: string;
33
+ input_cache_read?: string;
34
+ input_cache_write?: string;
35
+ };
36
+ supports_implicit_caching?: boolean;
37
+ supported_parameters?: string[];
38
+ }
39
+
40
+ interface OpenRouterEndpointsResponse {
41
+ data?: {
42
+ id?: string;
43
+ name?: string;
44
+ endpoints?: OpenRouterEndpointsEndpoint[];
45
+ };
46
+ }
47
+
48
+ export interface OpenRouterModelEndpointsMetadata {
49
+ modelId: string;
50
+ modelName?: string;
51
+ /** Provider summaries derived from endpoints. */
52
+ providers: OpenRouterInferenceProvider[];
53
+ /** Whether ANY endpoint supports reasoning controls. */
54
+ supportsReasoning: boolean;
55
+ }
56
+
57
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
58
+
59
+ type CachedModelEntry = {
60
+ timestamp: number;
61
+ data: OpenRouterModelEndpointsMetadata;
62
+ };
63
+
64
+ let cachedByModelId: Map<string, CachedModelEntry> | null = null;
65
+
66
+ function encodeModelIdForEndpointsPath(modelId: string): string {
67
+ // OpenRouter expects the slash to remain a path separator. Encode each segment.
68
+ return modelId.split("/").map(encodeURIComponent).join("/");
69
+ }
70
+
71
+ function parseEndpointPricingToPerMillion(
72
+ pricing: OpenRouterEndpointsEndpoint["pricing"]
73
+ ): ModelPricing | undefined {
74
+ if (!pricing || typeof pricing !== "object") return undefined;
75
+
76
+ const prompt = parseOpenRouterPricePerTokenToPerMillion(pricing.prompt);
77
+ const completion = parseOpenRouterPricePerTokenToPerMillion(pricing.completion);
78
+ if (prompt === undefined || completion === undefined) return undefined;
79
+
80
+ const parsed: ModelPricing = { prompt, completion };
81
+
82
+ const cacheReadPrice = parseOpenRouterPricePerTokenToPerMillion(pricing.input_cache_read);
83
+ if (cacheReadPrice !== undefined) parsed.inputCacheRead = cacheReadPrice;
84
+
85
+ const cacheWritePrice = parseOpenRouterPricePerTokenToPerMillion(pricing.input_cache_write);
86
+ if (cacheWritePrice !== undefined) parsed.inputCacheWrite = cacheWritePrice;
87
+
88
+ return parsed;
89
+ }
90
+
91
+ function endpointSupportsReasoning(endpoint: OpenRouterEndpointsEndpoint): boolean {
92
+ const params = endpoint.supported_parameters;
93
+ if (!Array.isArray(params)) return false;
94
+ return params.includes("reasoning") || params.includes("reasoning_effort");
95
+ }
96
+
97
+ async function fetchModelEndpointsMetadata(modelId: string): Promise<OpenRouterModelEndpointsMetadata> {
98
+ const encodedPath = encodeModelIdForEndpointsPath(modelId);
99
+ const url = `https://openrouter.ai/api/v1/models/${encodedPath}/endpoints`;
100
+
101
+ const response = await fetch(url, {
102
+ headers: {
103
+ "Content-Type": "application/json",
104
+ },
105
+ });
106
+
107
+ if (!response.ok) {
108
+ throw new Error(`OpenRouter endpoints API error: ${response.status}`);
109
+ }
110
+
111
+ const data = (await response.json()) as OpenRouterEndpointsResponse;
112
+ const endpoints = data.data?.endpoints ?? [];
113
+
114
+ const supportsReasoning = endpoints.some(endpointSupportsReasoning);
115
+
116
+ const byTag = new Map<string, OpenRouterInferenceProvider>();
117
+ const pricingByTag = new Map<string, ModelPricing[]>();
118
+
119
+ for (const endpoint of endpoints) {
120
+ const tag = typeof endpoint.tag === "string" ? endpoint.tag : "";
121
+ if (!tag) continue;
122
+
123
+ const providerName =
124
+ typeof endpoint.provider_name === "string" && endpoint.provider_name.trim().length > 0
125
+ ? endpoint.provider_name.trim()
126
+ : tag;
127
+
128
+ const existing = byTag.get(tag);
129
+ if (existing) {
130
+ const contextLength = typeof endpoint.context_length === "number" ? endpoint.context_length : undefined;
131
+ if (contextLength !== undefined) {
132
+ existing.contextLength = Math.max(existing.contextLength ?? 0, contextLength);
133
+ }
134
+ existing.supportsCaching =
135
+ existing.supportsCaching ||
136
+ endpoint.supports_implicit_caching === true ||
137
+ endpoint.pricing?.input_cache_read !== undefined ||
138
+ endpoint.pricing?.input_cache_write !== undefined;
139
+ }
140
+
141
+ if (!existing) {
142
+ const contextLength = typeof endpoint.context_length === "number" ? endpoint.context_length : undefined;
143
+ byTag.set(tag, {
144
+ tag,
145
+ providerName,
146
+ contextLength,
147
+ supportsCaching:
148
+ endpoint.supports_implicit_caching === true ||
149
+ endpoint.pricing?.input_cache_read !== undefined ||
150
+ endpoint.pricing?.input_cache_write !== undefined,
151
+ });
152
+ }
153
+
154
+ const parsedPricing = parseEndpointPricingToPerMillion(endpoint.pricing);
155
+ if (parsedPricing) {
156
+ const list = pricingByTag.get(tag);
157
+ if (list) {
158
+ list.push(parsedPricing);
159
+ } else {
160
+ pricingByTag.set(tag, [parsedPricing]);
161
+ }
162
+ }
163
+ }
164
+
165
+ const providers = Array.from(byTag.values())
166
+ .map((provider) => {
167
+ const prices = pricingByTag.get(provider.tag);
168
+ const merged = prices ? mergePricingAverages(prices) : undefined;
169
+ return merged ? { ...provider, pricing: merged } : provider;
170
+ })
171
+ .sort((a, b) => {
172
+ const byName = a.providerName.localeCompare(b.providerName);
173
+ if (byName !== 0) return byName;
174
+ return a.tag.localeCompare(b.tag);
175
+ });
176
+
177
+ return {
178
+ modelId,
179
+ modelName: typeof data.data?.name === "string" ? data.data.name : undefined,
180
+ providers,
181
+ supportsReasoning,
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Get the list of inference providers available for a given model.
187
+ * Results are cached in-memory for a day.
188
+ */
189
+ export async function getOpenRouterModelProviders(modelId: string): Promise<OpenRouterInferenceProvider[]> {
190
+ const metadata = await getOpenRouterModelEndpointsMetadata(modelId);
191
+ return metadata?.providers ?? [];
192
+ }
193
+
194
+ export async function getOpenRouterModelEndpointsMetadata(
195
+ modelId: string
196
+ ): Promise<OpenRouterModelEndpointsMetadata | null> {
197
+ const now = Date.now();
198
+ if (!cachedByModelId) cachedByModelId = new Map();
199
+
200
+ const cached = cachedByModelId.get(modelId);
201
+ if (cached && now - cached.timestamp < CACHE_TTL_MS) return cached.data;
202
+
203
+ try {
204
+ debug.log("Fetching OpenRouter endpoints for model...", { modelId });
205
+ const data = await fetchModelEndpointsMetadata(modelId);
206
+ cachedByModelId.set(modelId, { timestamp: now, data });
207
+ return data;
208
+ } catch (error) {
209
+ debug.error("Failed to fetch OpenRouter endpoints:", error);
210
+ return cached?.data ?? null;
211
+ }
212
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Fetches and caches the full OpenRouter models list.
3
+ * This is used to populate the "all models" picker section.
4
+ */
5
+
6
+ import { promises as fs } from "node:fs";
7
+ import path from "node:path";
8
+ import type { ModelOption, ModelPricing } from "../types";
9
+ import { debug } from "./debug-logger";
10
+ import { getAppConfigDir } from "./preferences";
11
+ import { parseOpenRouterPricePerTokenToPerMillion } from "./openrouter-pricing";
12
+
13
+ interface OpenRouterModelItem {
14
+ id?: string;
15
+ name?: string;
16
+ context_length?: number;
17
+ pricing?: {
18
+ prompt?: string;
19
+ completion?: string;
20
+ input_cache_read?: string;
21
+ input_cache_write?: string;
22
+ };
23
+ supports_tools?: boolean;
24
+ supported_parameters?: string[];
25
+ }
26
+
27
+ interface OpenRouterModelsResponse {
28
+ data?: OpenRouterModelItem[];
29
+ }
30
+
31
+ interface OpenRouterModelsCache {
32
+ timestamp: number;
33
+ models: ModelOption[];
34
+ }
35
+
36
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
37
+ const CACHE_FILENAME = "openrouter-models.json";
38
+
39
+ let inMemoryCache: OpenRouterModelsCache | null = null;
40
+
41
+ function getCachePath(): string {
42
+ return path.join(getAppConfigDir(), CACHE_FILENAME);
43
+ }
44
+
45
+ function parsePricing(pricing?: OpenRouterModelItem["pricing"]): ModelPricing | undefined {
46
+ if (!pricing || typeof pricing !== "object") return undefined;
47
+
48
+ const prompt = parseOpenRouterPricePerTokenToPerMillion(pricing.prompt);
49
+ const completion = parseOpenRouterPricePerTokenToPerMillion(pricing.completion);
50
+ if (prompt === undefined || completion === undefined) return undefined;
51
+
52
+ const parsed: ModelPricing = { prompt, completion };
53
+
54
+ const cacheRead = parseOpenRouterPricePerTokenToPerMillion(pricing.input_cache_read);
55
+ if (cacheRead !== undefined) parsed.inputCacheRead = cacheRead;
56
+
57
+ const cacheWrite = parseOpenRouterPricePerTokenToPerMillion(pricing.input_cache_write);
58
+ if (cacheWrite !== undefined) parsed.inputCacheWrite = cacheWrite;
59
+
60
+ return parsed;
61
+ }
62
+
63
+ function supportsToolCalling(model: OpenRouterModelItem): boolean {
64
+ if (typeof model.supports_tools === "boolean") return model.supports_tools;
65
+
66
+ const params = Array.isArray(model.supported_parameters)
67
+ ? model.supported_parameters.map((p) => p.toLowerCase())
68
+ : [];
69
+
70
+ if (params.length > 0) {
71
+ const toolParams = new Set(["tools", "tool_choice", "functions", "function_call", "tool-call"]);
72
+ return params.some((p) => toolParams.has(p));
73
+ }
74
+
75
+ // If the API doesn't expose tool support, exclude the model by default.
76
+ return false;
77
+ }
78
+
79
+ function normalizeModels(items: OpenRouterModelItem[]): ModelOption[] {
80
+ const models: ModelOption[] = [];
81
+
82
+ for (const item of items) {
83
+ const id = typeof item.id === "string" ? item.id.trim() : "";
84
+ if (!id) continue;
85
+ if (!supportsToolCalling(item)) continue;
86
+
87
+ const name = typeof item.name === "string" && item.name.trim().length > 0 ? item.name.trim() : id;
88
+ const contextLength = typeof item.context_length === "number" ? item.context_length : undefined;
89
+ const pricing = parsePricing(item.pricing);
90
+
91
+ models.push({
92
+ id,
93
+ name,
94
+ contextLength,
95
+ pricing,
96
+ });
97
+ }
98
+
99
+ return models;
100
+ }
101
+
102
+ async function readCache(): Promise<OpenRouterModelsCache | null> {
103
+ try {
104
+ const payload = await fs.readFile(getCachePath(), "utf8");
105
+ const data = JSON.parse(payload) as OpenRouterModelsCache;
106
+ if (!data || typeof data.timestamp !== "number" || !Array.isArray(data.models)) return null;
107
+ return data;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+
113
+ async function writeCache(cache: OpenRouterModelsCache): Promise<void> {
114
+ try {
115
+ const dir = getAppConfigDir();
116
+ await fs.mkdir(dir, { recursive: true });
117
+ await fs.writeFile(getCachePath(), JSON.stringify(cache, null, 2), "utf8");
118
+ } catch (error) {
119
+ debug.error("Failed to write OpenRouter models cache:", error);
120
+ }
121
+ }
122
+
123
+ async function fetchOpenRouterModelsFromApi(): Promise<OpenRouterModelsCache> {
124
+ const url = "https://openrouter.ai/api/v1/models";
125
+ const headers: Record<string, string> = {
126
+ "Content-Type": "application/json",
127
+ };
128
+
129
+ const apiKey = process.env.OPENROUTER_API_KEY;
130
+ if (apiKey) {
131
+ headers.Authorization = `Bearer ${apiKey}`;
132
+ }
133
+
134
+ const response = await fetch(url, { headers });
135
+ if (!response.ok) {
136
+ throw new Error(`OpenRouter models API error: ${response.status}`);
137
+ }
138
+
139
+ const data = (await response.json()) as OpenRouterModelsResponse;
140
+ const items = Array.isArray(data.data) ? data.data : [];
141
+
142
+ const models = normalizeModels(items);
143
+ const payload: OpenRouterModelsCache = {
144
+ timestamp: Date.now(),
145
+ models,
146
+ };
147
+
148
+ await writeCache(payload);
149
+ return payload;
150
+ }
151
+
152
+ function isCacheFresh(cache: OpenRouterModelsCache): boolean {
153
+ return Date.now() - cache.timestamp < CACHE_TTL_MS;
154
+ }
155
+
156
+ export interface OpenRouterModelsResult {
157
+ models: ModelOption[];
158
+ timestamp: number | null;
159
+ fromCache: boolean;
160
+ }
161
+
162
+ export async function getOpenRouterModels(options?: {
163
+ forceRefresh?: boolean;
164
+ }): Promise<OpenRouterModelsResult> {
165
+ const forceRefresh = options?.forceRefresh ?? false;
166
+
167
+ if (!forceRefresh && inMemoryCache && isCacheFresh(inMemoryCache)) {
168
+ return {
169
+ models: inMemoryCache.models,
170
+ timestamp: inMemoryCache.timestamp,
171
+ fromCache: true,
172
+ };
173
+ }
174
+
175
+ if (!forceRefresh) {
176
+ const diskCache = await readCache();
177
+ if (diskCache && isCacheFresh(diskCache)) {
178
+ inMemoryCache = diskCache;
179
+ return {
180
+ models: diskCache.models,
181
+ timestamp: diskCache.timestamp,
182
+ fromCache: true,
183
+ };
184
+ }
185
+ }
186
+
187
+ try {
188
+ debug.log("Fetching OpenRouter model list...");
189
+ const cache = await fetchOpenRouterModelsFromApi();
190
+ inMemoryCache = cache;
191
+ return { models: cache.models, timestamp: cache.timestamp, fromCache: false };
192
+ } catch (error) {
193
+ debug.error("Failed to fetch OpenRouter models:", error);
194
+ const fallback = inMemoryCache ?? (await readCache());
195
+ if (fallback) {
196
+ inMemoryCache = fallback;
197
+ return {
198
+ models: fallback.models,
199
+ timestamp: fallback.timestamp,
200
+ fromCache: true,
201
+ };
202
+ }
203
+ return { models: [], timestamp: null, fromCache: true };
204
+ }
205
+ }
@@ -0,0 +1,59 @@
1
+ import type { ModelPricing } from "../types";
2
+
3
+ export function parseOpenRouterPricePerTokenToPerMillion(pricePerToken: unknown): number | undefined {
4
+ if (typeof pricePerToken !== "string") return undefined;
5
+ const parsed = Number(pricePerToken);
6
+ if (!Number.isFinite(parsed)) return undefined;
7
+ return parsed * 1_000_000;
8
+ }
9
+
10
+ export function mergePricingAverages(pricings: ModelPricing[]): ModelPricing | undefined {
11
+ let promptSum = 0;
12
+ let promptCount = 0;
13
+
14
+ let completionSum = 0;
15
+ let completionCount = 0;
16
+
17
+ let inputCacheReadSum = 0;
18
+ let inputCacheReadCount = 0;
19
+
20
+ let inputCacheWriteSum = 0;
21
+ let inputCacheWriteCount = 0;
22
+
23
+ for (const pricing of pricings) {
24
+ if (Number.isFinite(pricing.prompt)) {
25
+ promptSum += pricing.prompt;
26
+ promptCount += 1;
27
+ }
28
+ if (Number.isFinite(pricing.completion)) {
29
+ completionSum += pricing.completion;
30
+ completionCount += 1;
31
+ }
32
+
33
+ if (pricing.inputCacheRead !== undefined && Number.isFinite(pricing.inputCacheRead)) {
34
+ inputCacheReadSum += pricing.inputCacheRead;
35
+ inputCacheReadCount += 1;
36
+ }
37
+ if (pricing.inputCacheWrite !== undefined && Number.isFinite(pricing.inputCacheWrite)) {
38
+ inputCacheWriteSum += pricing.inputCacheWrite;
39
+ inputCacheWriteCount += 1;
40
+ }
41
+ }
42
+
43
+ if (promptCount === 0 || completionCount === 0) return undefined;
44
+
45
+ const merged: ModelPricing = {
46
+ prompt: promptSum / promptCount,
47
+ completion: completionSum / completionCount,
48
+ };
49
+
50
+ if (inputCacheReadCount > 0) {
51
+ merged.inputCacheRead = inputCacheReadSum / inputCacheReadCount;
52
+ }
53
+
54
+ if (inputCacheWriteCount > 0) {
55
+ merged.inputCacheWrite = inputCacheWriteSum / inputCacheWriteCount;
56
+ }
57
+
58
+ return merged;
59
+ }
@@ -0,0 +1,16 @@
1
+ export function getOpenRouterReportedCost(providerMetadata: unknown): number | undefined {
2
+ if (!providerMetadata || typeof providerMetadata !== "object") return undefined;
3
+ if (!("openrouter" in providerMetadata)) return undefined;
4
+
5
+ const openrouter = (providerMetadata as { openrouter?: unknown }).openrouter;
6
+ if (!openrouter || typeof openrouter !== "object") return undefined;
7
+ if (!("usage" in openrouter)) return undefined;
8
+
9
+ const usage = (openrouter as { usage?: unknown }).usage;
10
+ if (!usage || typeof usage !== "object") return undefined;
11
+ if (!("cost" in usage)) return undefined;
12
+
13
+ const cost = (usage as { cost?: unknown }).cost;
14
+ if (typeof cost !== "number" || !Number.isFinite(cost)) return undefined;
15
+ return cost;
16
+ }