Skip to content

Commit 0e82780

Browse files
Merge pull request #31 from Ditectrev/feature/fix-ollama
refactor: streamline AI prediction and stock of the day routes with n…
2 parents 0b90daf + 6ac65aa commit 0e82780

14 files changed

Lines changed: 258 additions & 135 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ STRIPE_WEBHOOK_SECRET=
2525
VERCEL_ORG_ID=
2626
VERCEL_PROJECT_ID=
2727
VERCEL_TOKEN=
28+
YAHOO_FINANCE_API_URL=

.github/workflows/deploy.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,16 @@ jobs:
180180
vercel env add APPWRITE_COLLECTION_ID_AI_KEYS $ENV_TYPE "" --value "${{ secrets.APPWRITE_COLLECTION_ID_AI_KEYS }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }}
181181
vercel env add APPWRITE_COLLECTION_ID_TRIAL_SESSIONS $ENV_TYPE "" --value "${{ secrets.APPWRITE_COLLECTION_ID_TRIAL_SESSIONS }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }}
182182
vercel env add APPWRITE_COLLECTION_ID_SUBSCRIPTIONS $ENV_TYPE "" --value "${{ secrets.APPWRITE_COLLECTION_ID_SUBSCRIPTIONS }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }}
183+
if [ -n "${{ vars.FINNHUB_BASE_URL }}" ]; then
184+
vercel env add FINNHUB_BASE_URL $ENV_TYPE "" --value "${{ vars.FINNHUB_BASE_URL }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }}
185+
fi
186+
vercel env add FINNHUB_API_KEY $ENV_TYPE "" --value "${{ secrets.FINNHUB_API_KEY }}" --yes --force --sensitive --token=${{ secrets.VERCEL_TOKEN }}
187+
if [ -n "${{ vars.OLLAMA_MODEL }}" ]; then
188+
vercel env add OLLAMA_MODEL $ENV_TYPE "" --value "${{ vars.OLLAMA_MODEL }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }}
189+
fi
190+
if [ -n "${{ vars.YAHOO_FINANCE_API_URL }}" ]; then
191+
vercel env add YAHOO_FINANCE_API_URL $ENV_TYPE "" --value "${{ vars.YAHOO_FINANCE_API_URL }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }}
192+
fi
183193
if [ -n "${{ vars.STRIPE_PRICE_ADS_FREE }}" ]; then
184194
vercel env add STRIPE_PRICE_ADS_FREE $ENV_TYPE "" --value "${{ vars.STRIPE_PRICE_ADS_FREE }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }}
185195
fi
@@ -216,6 +226,10 @@ jobs:
216226
APPWRITE_COLLECTION_ID_AI_KEYS: ${{ secrets.APPWRITE_COLLECTION_ID_AI_KEYS }}
217227
APPWRITE_COLLECTION_ID_TRIAL_SESSIONS: ${{ secrets.APPWRITE_COLLECTION_ID_TRIAL_SESSIONS }}
218228
APPWRITE_COLLECTION_ID_SUBSCRIPTIONS: ${{ secrets.APPWRITE_COLLECTION_ID_SUBSCRIPTIONS }}
229+
FINNHUB_BASE_URL: ${{ vars.FINNHUB_BASE_URL }}
230+
FINNHUB_API_KEY: ${{ secrets.FINNHUB_API_KEY }}
231+
OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }}
232+
YAHOO_FINANCE_API_URL: ${{ vars.YAHOO_FINANCE_API_URL }}
219233
STRIPE_PRICE_ADS_FREE: ${{ vars.STRIPE_PRICE_ADS_FREE }}
220234
STRIPE_PRICE_LOCAL: ${{ vars.STRIPE_PRICE_LOCAL }}
221235
STRIPE_PRICE_BYOK: ${{ vars.STRIPE_PRICE_BYOK }}

app/(main)/home-page-client.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,10 @@ export function HomePageClient() {
258258
`/api/market/symbol/${selectedSymbol}`
259259
);
260260
if (!symbolResponse.ok) {
261-
throw new Error("Failed to fetch symbol data");
261+
const body = (await symbolResponse.json().catch(() => ({}))) as {
262+
error?: string;
263+
};
264+
throw new Error(body.error ?? "Failed to fetch symbol data");
262265
}
263266
const symbolResult = await symbolResponse.json();
264267
setSymbolData(symbolResult.data);
@@ -531,6 +534,7 @@ export function HomePageClient() {
531534
loading={aiPredictionLoading}
532535
locked={!hasAIAccess}
533536
error={aiPredictionError}
537+
pricingTier={effectiveTier}
534538
/>
535539
</>
536540
)}
@@ -569,6 +573,7 @@ export function HomePageClient() {
569573
loading={stockOfTheDayLoading}
570574
locked={!hasAIAccess}
571575
error={stockOfTheDayError}
576+
pricingTier={effectiveTier}
572577
/>
573578
</div>
574579
</div>

app/(main)/stock-of-the-day/page.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export default function StockOfTheDayPage() {
102102
loading={loading}
103103
locked={!hasAIAccess}
104104
error={loadError}
105+
pricingTier={pricingTier}
105106
/>
106107
</div>
107108
);

app/api/market/ai-prediction/[symbol]/route.ts

Lines changed: 14 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,7 @@ import { aiMarketInsightsService } from "@/services/ai-market-insights.service";
33
import { logger } from "@/lib/logger";
44
import { getAuthenticatedUser } from "@/lib/server-auth";
55
import { subscriptionService } from "@/services/subscription.service";
6-
import { appwriteAIKeyStoreService } from "@/services/appwrite-ai-key-store.service";
7-
import type { AIProvider } from "@/types";
8-
import type { BYOKProvider } from "@/services/api-key-manager.service";
9-
10-
function isBYOKProvider(value: string): value is BYOKProvider {
11-
return ["OPENAI", "GEMINI", "MISTRAL", "DEEPSEEK"].includes(value);
12-
}
6+
import { resolveMarketRouteLLMConfig } from "@/lib/resolve-market-ai-llm-config";
137

148
export async function GET(
159
request: NextRequest,
@@ -43,54 +37,22 @@ export async function GET(
4337

4438
const requestedProviderRaw =
4539
request.headers.get("x-ai-provider")?.trim().toUpperCase() ?? "";
46-
const model = process.env.AI_MODEL;
47-
48-
let llmConfig:
49-
| {
50-
provider: AIProvider;
51-
apiKey?: string;
52-
model?: string;
53-
}
54-
| undefined;
5540

56-
if (tier === "LOCAL") {
57-
llmConfig = {
58-
provider: "OLLAMA",
59-
model: process.env.OLLAMA_MODEL ?? model,
60-
};
61-
} else if (tier === "BYOK") {
62-
const requestedProvider = isBYOKProvider(requestedProviderRaw)
63-
? requestedProviderRaw
64-
: null;
65-
const provider =
66-
requestedProvider ??
67-
(await appwriteAIKeyStoreService.getPreferredProvider(auth.id));
68-
if (!provider) {
69-
return NextResponse.json(
70-
{
71-
success: false,
72-
error:
73-
"No BYOK API key found. Save a provider key in profile first.",
74-
},
75-
{ status: 403 }
76-
);
77-
}
78-
const apiKey = await appwriteAIKeyStoreService.getDecryptedKey(
79-
auth.id,
80-
provider
81-
);
82-
if (!apiKey) {
83-
return NextResponse.json(
84-
{
85-
success: false,
86-
error: `No API key stored for provider ${provider}. Change provider in profile or save a key for ${provider}.`,
87-
},
88-
{ status: 403 }
89-
);
90-
}
91-
llmConfig = { provider, apiKey, model };
41+
const resolved = await resolveMarketRouteLLMConfig({
42+
tier,
43+
userId: auth.id,
44+
requestedProviderRaw,
45+
});
46+
if (!resolved.ok) {
47+
logger.warn("AI prediction: no LLM credentials; using heuristic only", {
48+
userId: auth.id,
49+
tier,
50+
symbol,
51+
detail: resolved.error,
52+
});
9253
}
9354

55+
const llmConfig = resolved.ok ? resolved.llmConfig : undefined;
9456
const data = await aiMarketInsightsService.generatePrediction(
9557
symbol,
9658
llmConfig

app/api/market/stock-of-the-day/route.ts

Lines changed: 14 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,7 @@ import { aiMarketInsightsService } from "@/services/ai-market-insights.service";
33
import { logger } from "@/lib/logger";
44
import { getAuthenticatedUser } from "@/lib/server-auth";
55
import { subscriptionService } from "@/services/subscription.service";
6-
import { appwriteAIKeyStoreService } from "@/services/appwrite-ai-key-store.service";
7-
import type { AIProvider } from "@/types";
8-
import type { BYOKProvider } from "@/services/api-key-manager.service";
9-
10-
function isBYOKProvider(value: string): value is BYOKProvider {
11-
return ["OPENAI", "GEMINI", "MISTRAL", "DEEPSEEK"].includes(value);
12-
}
6+
import { resolveMarketRouteLLMConfig } from "@/lib/resolve-market-ai-llm-config";
137

148
export async function GET(request: NextRequest) {
159
try {
@@ -31,54 +25,24 @@ export async function GET(request: NextRequest) {
3125

3226
const requestedProviderRaw =
3327
request.headers.get("x-ai-provider")?.trim().toUpperCase() ?? "";
34-
const model = process.env.AI_MODEL;
3528

36-
let llmConfig:
37-
| {
38-
provider: AIProvider;
39-
apiKey?: string;
40-
model?: string;
29+
const resolved = await resolveMarketRouteLLMConfig({
30+
tier,
31+
userId: auth.id,
32+
requestedProviderRaw,
33+
});
34+
if (!resolved.ok) {
35+
logger.warn(
36+
"Stock of the day: no LLM credentials; using heuristic only",
37+
{
38+
userId: auth.id,
39+
tier,
40+
detail: resolved.error,
4141
}
42-
| undefined;
43-
44-
if (tier === "LOCAL") {
45-
llmConfig = {
46-
provider: "OLLAMA",
47-
model: process.env.OLLAMA_MODEL ?? model,
48-
};
49-
} else if (tier === "BYOK") {
50-
const requestedProvider = isBYOKProvider(requestedProviderRaw)
51-
? requestedProviderRaw
52-
: null;
53-
const provider =
54-
requestedProvider ??
55-
(await appwriteAIKeyStoreService.getPreferredProvider(auth.id));
56-
if (!provider) {
57-
return NextResponse.json(
58-
{
59-
success: false,
60-
error:
61-
"No BYOK API key found. Save a provider key in profile first.",
62-
},
63-
{ status: 403 }
64-
);
65-
}
66-
const apiKey = await appwriteAIKeyStoreService.getDecryptedKey(
67-
auth.id,
68-
provider
6942
);
70-
if (!apiKey) {
71-
return NextResponse.json(
72-
{
73-
success: false,
74-
error: `No API key stored for provider ${provider}. Change provider in profile or save a key for ${provider}.`,
75-
},
76-
{ status: 403 }
77-
);
78-
}
79-
llmConfig = { provider, apiKey, model };
8043
}
8144

45+
const llmConfig = resolved.ok ? resolved.llmConfig : undefined;
8246
const data = await aiMarketInsightsService.getStockOfTheDay(llmConfig);
8347

8448
return NextResponse.json({

app/layout.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { Metadata } from "next";
22
import { Ibarra_Real_Nova, Merriweather } from "next/font/google";
3-
import Script from "next/script";
43
import "./globals.css";
54
import { Providers } from "./providers";
65

@@ -37,13 +36,16 @@ export default function RootLayout({
3736
<html lang="en">
3837
<head>
3938
{gtmId ? (
40-
<Script id="gtm-script" strategy="afterInteractive">
41-
{`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
39+
<script
40+
id="gtm-script"
41+
dangerouslySetInnerHTML={{
42+
__html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
4243
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
4344
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
4445
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
45-
})(window,document,'script','dataLayer','${gtmId}');`}
46-
</Script>
46+
})(window,document,'script','dataLayer','${gtmId}');`,
47+
}}
48+
/>
4749
) : null}
4850
<meta
4951
name="viewport"

components/AIPredictionPanel.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
"use client";
22

33
import Link from "next/link";
4-
import type { AIPredictionReport } from "@/types";
4+
import type { AIPredictionReport, PricingTier } from "@/types";
5+
import { getAiSubscriptionGateMessage } from "@/lib/ai-subscription-ux";
56
import { isMissingByokApiKeyMessage } from "@/lib/missing-byok-api-key";
67

78
interface AIPredictionPanelProps {
89
prediction: AIPredictionReport | null;
910
loading: boolean;
1011
locked: boolean;
1112
error?: string | null;
13+
pricingTier?: PricingTier | null;
1214
}
1315

1416
function RecommendationBadge({
@@ -38,6 +40,7 @@ export function AIPredictionPanel({
3840
loading,
3941
locked,
4042
error,
43+
pricingTier,
4144
}: AIPredictionPanelProps) {
4245
const politicalFactors = prediction?.politicalFactors ?? [];
4346
const financialTrendFactors = prediction?.financialTrendFactors ?? [];
@@ -155,7 +158,7 @@ export function AIPredictionPanel({
155158
{locked && (
156159
<div className="absolute inset-0 flex flex-col items-center justify-center bg-white/70 dark:bg-gray-900/70 px-6 text-center">
157160
<p className="text-sm sm:text-base font-medium text-gray-900 dark:text-gray-100">
158-
AI prediction is available only for AI subscriptions.
161+
{getAiSubscriptionGateMessage(pricingTier ?? undefined)}
159162
</p>
160163
<Link
161164
href="/pricing"

components/StockOfTheDayPanel.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
"use client";
22

33
import Link from "next/link";
4-
import type { StockOfTheDay } from "@/types";
4+
import type { PricingTier, StockOfTheDay } from "@/types";
5+
import { getAiSubscriptionGateMessage } from "@/lib/ai-subscription-ux";
56
import { isMissingByokApiKeyMessage } from "@/lib/missing-byok-api-key";
67

78
interface StockOfTheDayPanelProps {
89
item: StockOfTheDay | null;
910
loading: boolean;
1011
locked: boolean;
1112
error?: string | null;
13+
/** When locked, used to explain which upgrade path applies. */
14+
pricingTier?: PricingTier | null;
1215
}
1316

1417
export function StockOfTheDayPanel({
1518
item,
1619
loading,
1720
locked,
1821
error,
22+
pricingTier,
1923
}: StockOfTheDayPanelProps) {
2024
return (
2125
<section className="mt-6 sm:mt-8 lg:mt-10">
@@ -102,8 +106,7 @@ export function StockOfTheDayPanel({
102106
{locked && (
103107
<div className="absolute inset-0 flex flex-col items-center justify-center bg-white/70 dark:bg-gray-900/70 px-6 text-center">
104108
<p className="text-sm sm:text-base font-medium text-gray-900 dark:text-gray-100">
105-
AI section locked. Enable any AI subscription to reveal
106-
today&apos;s pick.
109+
{getAiSubscriptionGateMessage(pricingTier ?? undefined)}
107110
</p>
108111
<Link
109112
href="/pricing"

components/UserProfileMenu.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ const PROVIDER_OPTIONS: Array<{
2727
{
2828
id: "OLLAMA",
2929
name: "Ollama (Local)",
30-
subtitle: "Run locally on your machine",
31-
allowedTiers: ["LOCAL"],
30+
subtitle: "Run on your machine (no provider API key)",
31+
allowedTiers: ["LOCAL", "BYOK"],
3232
},
3333
{
3434
id: "OPENAI",
@@ -161,6 +161,30 @@ export function UserProfileMenu() {
161161
}
162162
}, []);
163163

164+
useEffect(() => {
165+
const allowedForTier = PROVIDER_OPTIONS.filter((p) =>
166+
p.allowedTiers.includes(tier)
167+
);
168+
if (allowedForTier.length === 0) {
169+
if (typeof window !== "undefined") {
170+
localStorage.removeItem("explanations_provider");
171+
}
172+
setSelectedExplanationProvider((prev) =>
173+
prev === "OPENAI" ? prev : "OPENAI"
174+
);
175+
return;
176+
}
177+
const currentOk = allowedForTier.some(
178+
(p) => p.id === selectedExplanationProvider
179+
);
180+
if (currentOk) return;
181+
const next = allowedForTier[0].id;
182+
setSelectedExplanationProvider(next);
183+
if (typeof window !== "undefined") {
184+
localStorage.setItem("explanations_provider", next);
185+
}
186+
}, [tier, selectedExplanationProvider]);
187+
164188
useEffect(() => {
165189
if (typeof window === "undefined") return;
166190
const params = new URLSearchParams(window.location.search);
@@ -331,6 +355,13 @@ export function UserProfileMenu() {
331355
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
332356
Explanations Provider
333357
</h3>
358+
{!["LOCAL", "BYOK", "HOSTED_AI"].includes(tier) && (
359+
<p className="text-xs text-amber-700 dark:text-amber-300/90">
360+
AI explanations (Ollama, cloud keys, or Ditectrev AI) unlock
361+
after you subscribe to an AI plan — Ads-free and Free tiers do
362+
not include server-side AI.
363+
</p>
364+
)}
334365
<div className="grid grid-cols-2 gap-2">
335366
{PROVIDER_OPTIONS.map((provider) => {
336367
const allowed = provider.allowedTiers.includes(tier);

0 commit comments

Comments
 (0)