Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app/components/chat/ToolCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { npmInstallToolParameters } from '~/lib/runtime/npmInstallTool';
import { loggingSafeParse } from '~/lib/zodUtil';
import { deployToolParameters } from '~/lib/runtime/deployTool';
import type { ZodError } from 'zod';
import { getRelativePath } from '~/lib/stores/files';

export const ToolCall = memo((props: { partId: PartId; toolCallId: string }) => {
const { partId, toolCallId } = props;
Expand Down Expand Up @@ -382,7 +383,7 @@ function toolTitle(invocation: ConvexToolInvocation): React.ReactNode {
extra = ` (lines ${start} - ${endName})`;
}
if (args.success) {
renderedPath = args.data.path || '/home/project';
renderedPath = getRelativePath(args.data.path) || '/home/project';
}
return (
<div className="flex items-center gap-2">
Expand Down Expand Up @@ -418,9 +419,9 @@ function toolTitle(invocation: ConvexToolInvocation): React.ReactNode {
} else if (invocation.result?.startsWith('Error:')) {
if (
// This is a hack, but `npx convex dev` prints this out when the typecheck fails
invocation.result.includes('To ignore failing typecheck') ||
invocation.result.includes('[ConvexTypecheck]') ||
// this is a bigger hack! TypeScript fails with error codes like TS
invocation.result.includes('Error TS')
invocation.result.includes('[FrontendTypecheck]')
) {
return (
<div className="flex items-center gap-2">
Expand Down
77 changes: 63 additions & 14 deletions app/lib/runtime/action-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,31 +372,80 @@ export class ActionRunner {
case 'deploy': {
const container = await this.#webcontainer;
await waitForContainerBootState(ContainerBootState.READY);
const convexProc = await container.spawn('sh', [
'-c',
'convex dev --once && tsc --noEmit -p tsconfig.app.json',
]);
action.abortSignal.addEventListener('abort', () => {
convexProc.kill();
});
result = '';

const convexTypecheckProc = await container.spawn('npx', ['tsc', '-p', 'convex']);
let abortListener: () => void = () => {
convexTypecheckProc.kill();
};
action.abortSignal.addEventListener('abort', abortListener);

const { output, exitCode } = await streamOutput(convexProc, {
const { output: convexTypecheckOutput, exitCode: convexTypecheckExitCode } = await streamOutput(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tom pointed out we need to do codegen first

convexTypecheckProc,
{
onOutput: (output) => {
this.terminalOutput.set(output);
},
debounceMs: 50,
},
);
const cleanedTypecheckOutput = cleanConvexOutput(convexTypecheckOutput);
if (convexTypecheckExitCode !== 0) {
throw new Error(
`[ConvexTypecheck] Failed with exit code ${convexTypecheckExitCode}: ${cleanedTypecheckOutput}`,
);
}
result += cleanedTypecheckOutput + '\n\n';
action.abortSignal.removeEventListener('abort', abortListener);

const convexDevProc = await container.spawn('npx', ['convex', 'dev', '--once', '--typecheck', 'disable']);
abortListener = () => {
convexDevProc.kill();
};
action.abortSignal.addEventListener('abort', abortListener);

const { output: convexDevOutput, exitCode: convexDevExitCode } = await streamOutput(convexDevProc, {
onOutput: (output) => {
this.terminalOutput.set(output);
this.terminalOutput.set(result + output);
},
debounceMs: 50,
});
const cleanedOutput = cleanConvexOutput(output);
if (exitCode !== 0) {
throw new Error(`Convex failed with exit code ${exitCode}: ${cleanedOutput}`);
const cleanedDevOutput = cleanConvexOutput(convexDevOutput);
if (convexDevExitCode !== 0) {
throw new Error(`[ConvexDev] Failed with exit code ${convexDevExitCode}: ${cleanedDevOutput}`);
}
result += cleanedDevOutput + '\n\n';
action.abortSignal.removeEventListener('abort', abortListener);

const frontendTypecheckProc = await container.spawn('npx', ['tsc', '--noEmit', '-p', 'tsconfig.app.json']);
abortListener = () => {
frontendTypecheckProc.kill();
};
action.abortSignal.addEventListener('abort', abortListener);

const { output: frontendTypecheckOutput, exitCode: frontendTypecheckExitCode } = await streamOutput(
frontendTypecheckProc,
{
onOutput: (output) => {
this.terminalOutput.set(result + output);
},
debounceMs: 50,
},
);
const cleanedFrontendTypecheckOutput = cleanConvexOutput(frontendTypecheckOutput);
if (frontendTypecheckExitCode !== 0) {
throw new Error(
`[FrontendTypecheck] Failed with exit code ${frontendTypecheckExitCode}: ${cleanedFrontendTypecheckOutput}`,
);
}
result = cleanedOutput;
result += cleanedFrontendTypecheckOutput + '\n\n';
action.abortSignal.removeEventListener('abort', abortListener);

// Start the default preview if it’s not already running
if (!workbenchStore.isDefaultPreviewRunning()) {
const shell = this.#shellTerminal();
await shell.startCommand('npx vite --open');
result += '\n\nDev server started successfully!';
result += 'Dev server started successfully!';
}

break;
Expand Down
196 changes: 101 additions & 95 deletions convex/openaiProxy.ts
Original file line number Diff line number Diff line change
@@ -1,115 +1,121 @@
import { ConvexError, v } from "convex/values";
import { httpAction, internalMutation, mutation } from "./_generated/server";
import { getCurrentMember } from "./sessions";
import { internal } from "./_generated/api";
import { ConvexError, v } from 'convex/values';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(ignore this -- we don't have linters on the convex directory so my editor reformatted)

import { httpAction, internalMutation, mutation } from './_generated/server';
import { getCurrentMember } from './sessions';
import { internal } from './_generated/api';

export const openaiProxy = httpAction(async (ctx, req) => {
if (!openaiProxyEnabled()) {
return new Response("Convex OpenAI proxy is disabled.", { status: 400 });
}
if (!process.env.OPENAI_API_KEY) {
throw new Error("OPENAI_API_KEY is not set");
}
const headers = new Headers(req.headers);
const authHeader = headers.get("Authorization");
if (!authHeader) {
return new Response("Unauthorized", { status: 401 });
}
if (!authHeader.startsWith("Bearer ")) {
return new Response("Invalid authorization header", { status: 401 });
}
const token = authHeader.slice(7);
const result = await ctx.runMutation(internal.openaiProxy.decrementToken, { token });
if (!result.success) {
return new Response(result.error, { status: 401 });
}
if (!openaiProxyEnabled()) {
return new Response('Convex OpenAI proxy is disabled.', { status: 400 });
}
if (!process.env.OPENAI_API_KEY) {
throw new Error('OPENAI_API_KEY is not set');
}
const headers = new Headers(req.headers);
const authHeader = headers.get('Authorization');
if (!authHeader) {
return new Response('Unauthorized', { status: 401 });
}
if (!authHeader.startsWith('Bearer ')) {
return new Response('Invalid authorization header', { status: 401 });
}
const token = authHeader.slice(7);
const result = await ctx.runMutation(internal.openaiProxy.decrementToken, { token });
if (!result.success) {
return new Response(result.error, { status: 401 });
}

let body: any;
try {
body = await req.json();
} catch (error) {
return new Response("Invalid request body", { status: 400 });
}
if (body.model != 'gpt-4o-mini') {
return new Response("Only gpt-4o-mini is supported", { status: 400 });
}
let body: any;
try {
body = await req.json();
} catch (error) {
return new Response('Invalid request body', { status: 400 });
}
if (body.model != 'gpt-4o-mini') {
return new Response('Only gpt-4o-mini is supported', { status: 400 });
}

const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify(body),
});
return response;
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify(body),
});
return response;
});

export const issueOpenAIToken = mutation({
handler: async (ctx) => {
if (!openaiProxyEnabled()) {
return null;
}
const member = await getCurrentMember(ctx);
if (!member) {
console.error("Not authorized", member)
return null;
}
const existing = await ctx.db.query('memberOpenAITokens')
.withIndex('byMemberId', (q) => q.eq('memberId', member._id))
.unique();
if (existing) {
return existing.token;
}
const token = crypto.randomUUID();
await ctx.db.insert('memberOpenAITokens', {
memberId: member._id,
token,
requestsRemaining: included4oMiniRequests(),
lastUsedTime: 0,
});
return token;
handler: async (ctx) => {
if (!openaiProxyEnabled()) {
return null;
}
const member = await getCurrentMember(ctx);
if (!member) {
console.error('Not authorized', member);
return null;
}
})
const existing = await ctx.db
.query('memberOpenAITokens')
.withIndex('byMemberId', (q) => q.eq('memberId', member._id))
.unique();
if (existing) {
return existing.token;
}
const token = crypto.randomUUID();
await ctx.db.insert('memberOpenAITokens', {
memberId: member._id,
token,
requestsRemaining: included4oMiniRequests(),
lastUsedTime: 0,
});
return token;
},
});

export const decrementToken = internalMutation({
args: {
token: v.string(),
},
handler: async (ctx, args) => {
if (!openaiProxyEnabled()) {
return { success: false, error: "Convex OpenAI proxy is disabled."};
}
const token = await ctx.db.query('memberOpenAITokens')
.withIndex('byToken', (q) => q.eq('token', args.token))
.unique();
if (!token) {
return { success: false, error: "Invalid OPENAI_API_TOKEN" };
}
if (token.requestsRemaining <= 0) {
return { success: false, error: "Convex OPENAI_API_TOKEN has no requests remaining. Go sign up for an OpenAI API key at https://platform.openai.com and update your app to use that." };
}
await ctx.db.patch(token._id, {
requestsRemaining: token.requestsRemaining - 1,
lastUsedTime: Date.now(),
});
return { success: true };
args: {
token: v.string(),
},
handler: async (ctx, args) => {
if (!openaiProxyEnabled()) {
return { success: false, error: 'Convex OpenAI proxy is disabled.' };
}
const token = await ctx.db
.query('memberOpenAITokens')
.withIndex('byToken', (q) => q.eq('token', args.token))
.unique();
if (!token) {
return { success: false, error: 'Invalid OPENAI_API_TOKEN' };
}
})
if (token.requestsRemaining <= 0) {
return {
success: false,
error:
'Convex OPENAI_API_TOKEN has no requests remaining. Go sign up for an OpenAI API key at https://platform.openai.com and update your app to use that.',
};
}
await ctx.db.patch(token._id, {
requestsRemaining: token.requestsRemaining - 1,
lastUsedTime: Date.now(),
});
return { success: true };
},
});

// Cost per gpt-4o-mini request (2025-04-09):
// 16384 max output tokens @ $0.6/1M
// 128K max input tokens @ $0.15/1M
// => ~$0.03 per request.
function included4oMiniRequests() {
const fromEnv = process.env.OPENAI_PROXY_INCLUDED_REQUESTS;
if (!fromEnv) {
return 100;
}
return Number(fromEnv);
const fromEnv = process.env.OPENAI_PROXY_INCLUDED_REQUESTS;
if (!fromEnv) {
return 100;
}
return Number(fromEnv);
}

function openaiProxyEnabled() {
const fromEnv = process.env.OPENAI_PROXY_ENABLED;
return fromEnv && fromEnv == "1";
}
const fromEnv = process.env.OPENAI_PROXY_ENABLED;
return fromEnv && fromEnv == '1';
}