Compare commits

...
Author SHA1 Message Date
Ruben FiszelandClaude Opus 4.7 e694a6a045 fix(frontend): make Responses-API setup errors catchable so fallback fires
Addresses review finding #4 on #8933.

Before: getOpenAIResponsesCompletionStream was an `async function*` (async
generator). Calling an async generator returns an iterator synchronously
and never throws — the generator body only runs when the caller starts
iterating. So the try/catch around the call site in lib.ts (which is
supposed to fall back to the Completions API on failure) never caught
anything, and any setup error (dynamic SDK import, getOpenaiClient,
responses.stream initial request) surfaced as a rejection on the first
chunk read in the consumer rather than as a fallback trigger.

After: getOpenAIResponsesCompletionStream is a regular `async function`
that performs the setup (await the client, construct the stream request)
in its body, then returns an inner async iterable for the streaming
loop. The caller in lib.ts now awaits it, so setup failures are caught
by the existing try/catch and the Completions API fallback fires as
intended.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 17:52:14 +00:00
Ruben FiszelandClaude Opus 4.7 7ce8e4f581 perf(frontend): truly lazy AI SDK load and isolate provider failures
Addresses review feedback on #8933:

1. WorkspacedAIClients.init() no longer constructs the OpenAI/Anthropic
   clients up front. It only records the proxy baseURL; the clients (and
   thus the dynamic `import('openai' | '@anthropic-ai/sdk')`) are
   constructed lazily on the first getOpenaiClient() / getAnthropicClient()
   call. This makes the vendor-ai chunk load on first Copilot request
   instead of on every page mount that runs init() via the sidebar /
   AiChatLayout / InstanceAISettings.

2. ChatClients.openai / .anthropic now hold Promise<Client> and
   AIChatManager.chatRequest passes them as object getters, so only the
   provider actually used in a given chat iteration triggers its
   SDK-loading promise. This avoids the previous Promise.all(...) race
   where a construction failure in the unused provider would reject
   before the real provider could stream.

3. Option types updated to accept OpenAI | Promise<OpenAI> (and the
   Anthropic equivalent) since the implementations already `await` the
   value, which is a no-op for non-Promises.

Verified: build/200.html preload list is unchanged from production
(~165 KB of shell, no vendor chunk); logged-layout node 3 no longer
references the AI SDK chunk at all (not static, not in __vite__mapDeps).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:29:33 +00:00
Ruben FiszelandClaude Opus 4.7 90d82b2e84 fix(frontend): revert manualChunks grouping (regressed login payload)
The manualChunks config from the previous commit over-grouped Monaco
into a single `vendor-monaco` chunk. Any small utility touched by the
login page graph was enough to drag the full 6.5 MB Monaco bundle into
the initial preload, so the Cloudflare preview of the login page was
downloading ~7 MB while production downloads ~166 KB (a ~44x
regression).

Reverting the manualChunks function. Without it, Rollup's default
chunking splits Monaco fine-grainedly by usage pattern and the login
preload drops back to ~165 KB, matching production.

The OpenAI / Anthropic SDK dynamic-import refactor from the first
commit stays in place (it uses await import(...), which creates its
own chunks naturally and does not have this problem).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:11:48 +00:00
Ruben Fiszel 7a9c7687f7 Merge branch 'main' into frontend-assets-audit 2026-04-24 05:56:12 -07:00
Ruben FiszelandClaude Opus 4.7 54e1862909 perf(frontend): split vendor chunks and lazy-load AI SDKs
Two bundle-size wins for the frontend, focused on first-paint for logged-in
users (the production build is 83 MB across 624 JS files today):

- Configure Vite manualChunks so Monaco, ag-grid/charts, AI SDKs, markdown,
  @xyflow+d3-dag, yjs, pdfjs, chart.js, quicktype and quill each land in a
  single cacheable vendor chunk instead of being fragmented across route
  chunks. No byte reduction, but users stop redownloading Monaco on every
  navigation and the number of JS files drops from 624 to 583.

- Convert the OpenAI and Anthropic SDK imports in copilot/lib.ts (and the
  chat/* helpers) to dynamic imports. The SDKs were previously imported
  eagerly through copilot/lib -> aiStore -> sidebar/WorkspaceMenu, so every
  logged-in page paid ~41 KB gzipped for code only used by Copilot. The
  SDKs now live in a separate chunk loaded on first construction of a
  client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:54:05 +00:00
5 changed files with 98 additions and 90 deletions
@@ -416,6 +416,8 @@ class AIChatManager {
// on each iteration. This is critical for changeModeTool (Navigator → Script/Flow)
// which reassigns this.tools, this.helpers, this.systemMessage mid-loop.
const self = this
// Pass promise-holders rather than awaited clients so a construction
// failure in the unused provider doesn't block a chat on the other.
const result = await runChatLoop({
messages,
get systemMessage() {
@@ -433,8 +435,12 @@ class AIChatManager {
return getCurrentModel()
},
clients: {
openai: workspaceAIClients.getOpenaiClient(),
anthropic: workspaceAIClients.getAnthropicClient()
get openai() {
return workspaceAIClients.getOpenaiClient()
},
get anthropic() {
return workspaceAIClients.getAnthropicClient()
}
},
workspace: get(workspaceStore) ?? '',
skipResponsesApi: this.skipResponsesApi,
@@ -1,5 +1,5 @@
import { OpenAI } from 'openai'
import Anthropic from '@anthropic-ai/sdk'
import type { OpenAI } from 'openai'
import type Anthropic from '@anthropic-ai/sdk'
import type {
ChatCompletionMessageParam,
ChatCompletionMessageFunctionToolCall
@@ -30,7 +30,7 @@ export async function getAnthropicCompletion(
tools?: OpenAI.Chat.Completions.ChatCompletionFunctionTool[],
options?: {
forceModelProvider?: AIProviderModel
anthropicClient?: Anthropic
anthropicClient?: Anthropic | Promise<Anthropic>
}
): Promise<MessageStream> {
const { provider, config } = getProviderAndCompletionConfig({
@@ -41,7 +41,7 @@ export async function getAnthropicCompletion(
const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages)
const anthropicTools = convertOpenAIToolsToAnthropic(tools)
const client = options?.anthropicClient ?? workspaceAIClients.getAnthropicClient()
const client = await (options?.anthropicClient ?? workspaceAIClients.getAnthropicClient())
const anthropicParams = {
model: config.model,
@@ -1,5 +1,5 @@
import OpenAI from 'openai'
import Anthropic from '@anthropic-ai/sdk'
import type OpenAI from 'openai'
import type Anthropic from '@anthropic-ai/sdk'
import type {
ChatCompletionMessageParam,
ChatCompletionSystemMessageParam,
@@ -8,20 +8,13 @@ import type {
import type { AIProviderModel } from '$lib/gen'
import { getCompletion, parseOpenAICompletion } from '../lib'
import { getAnthropicCompletion, parseAnthropicCompletion } from './anthropic'
import {
getOpenAIResponsesCompletion,
parseOpenAIResponsesCompletion
} from './openai-responses'
import { getOpenAIResponsesCompletion, parseOpenAIResponsesCompletion } from './openai-responses'
import type { Tool, ToolCallbacks } from './shared'
import {
addChatTokenUsage,
emptyChatTokenUsage,
type ChatTokenUsage
} from './tokenUsage'
import { addChatTokenUsage, emptyChatTokenUsage, type ChatTokenUsage } from './tokenUsage'
export interface ChatClients {
openai: OpenAI
anthropic: Anthropic
openai: Promise<OpenAI>
anthropic: Promise<Anthropic>
}
export interface ChatLoopConfig {
@@ -137,10 +130,7 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
break
}
} catch (err) {
console.warn(
'OpenAI Responses API failed, falling back to Completions API:',
err
)
console.warn('OpenAI Responses API failed, falling back to Completions API:', err)
const errorMessage = err instanceof Error ? err.message : String(err)
if (errorMessage.includes('Responses API is not enabled')) {
skipResponsesApi = true
@@ -172,15 +162,10 @@ export async function runChatLoop(config: ChatLoopConfig): Promise<ChatLoopResul
}
}
} else if (isAnthropic) {
const completion = await getAnthropicCompletion(
messageParams,
abortController,
toolDefs,
{
forceModelProvider: modelProvider,
anthropicClient: clients.anthropic
}
)
const completion = await getAnthropicCompletion(messageParams, abortController, toolDefs, {
forceModelProvider: modelProvider,
anthropicClient: clients.anthropic
})
if (completion) {
const continueCompletion = await parseAnthropicCompletion(
completion,
@@ -1,4 +1,5 @@
import OpenAI, { OpenAIError } from 'openai'
import type OpenAI from 'openai'
import type { OpenAIError } from 'openai'
import type {
ChatCompletionMessageParam,
ChatCompletionMessageFunctionToolCall,
@@ -14,10 +15,7 @@ import {
import { processToolCall, type Tool, type ToolCallbacks } from './shared'
import type { ResponseStream } from 'openai/lib/responses/ResponseStream.mjs'
import type { AIProviderModel } from '$lib/gen'
import {
openAIResponsesUsageToChatTokenUsage,
type ChatTokenUsage
} from './tokenUsage'
import { openAIResponsesUsageToChatTokenUsage, type ChatTokenUsage } from './tokenUsage'
interface ParsedCompletionResult {
shouldContinue: boolean
@@ -137,7 +135,7 @@ export async function getOpenAIResponsesCompletion(
tools?: OpenAI.Chat.Completions.ChatCompletionTool[],
options?: {
forceModelProvider?: AIProviderModel
openaiClient?: OpenAI
openaiClient?: OpenAI | Promise<OpenAI>
}
) {
const { provider, config } = getProviderAndCompletionConfig({
@@ -149,7 +147,7 @@ export async function getOpenAIResponsesCompletion(
const { instructions, input } = convertMessagesToResponsesInput(messages)
const responsesConfig = convertCompletionConfigToResponsesConfig(config)
const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient()
const client = await (options?.openaiClient ?? workspaceAIClients.getOpenaiClient())
const runner = client.responses.stream(
{
@@ -168,17 +166,20 @@ export async function getOpenAIResponsesCompletion(
return runner
}
// Wrapper that converts ResponseStream to ChatCompletionChunk format for lib.ts usage
export async function* getOpenAIResponsesCompletionStream(
// Wrapper that converts ResponseStream to ChatCompletionChunk format for lib.ts usage.
// Returns an AsyncIterable so setup failures (SDK dynamic import, client construction,
// responses.stream() request) surface at the await in the caller and the caller can
// fall back to the Completions API.
export async function getOpenAIResponsesCompletionStream(
messages: ChatCompletionMessageParam[],
abortController: AbortController,
tools?: OpenAI.Chat.Completions.ChatCompletionTool[]
): AsyncGenerator<OpenAI.Chat.Completions.ChatCompletionChunk> {
): Promise<AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>> {
const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, tools })
const { instructions, input } = convertMessagesToResponsesInput(messages)
const responsesConfig = convertCompletionConfigToResponsesConfig(config)
const openaiClient = workspaceAIClients.getOpenaiClient()
const openaiClient = await workspaceAIClients.getOpenaiClient()
const runner = openaiClient.responses.stream(
{
@@ -194,27 +195,31 @@ export async function* getOpenAIResponsesCompletionStream(
}
)
// Convert ResponseStream events to ChatCompletionChunk format
for await (const event of runner) {
if (event.type === 'response.output_text.delta') {
// Yield text chunks in ChatCompletionChunk format
yield {
id: 'chatcmpl-' + Date.now(),
object: 'chat.completion.chunk',
created: Date.now(),
model: responsesConfig.model,
choices: [
{
index: 0,
delta: {
content: event.delta || ''
},
finish_reason: null
}
]
} as OpenAI.Chat.Completions.ChatCompletionChunk
async function* iterate() {
// Convert ResponseStream events to ChatCompletionChunk format
for await (const event of runner) {
if (event.type === 'response.output_text.delta') {
// Yield text chunks in ChatCompletionChunk format
yield {
id: 'chatcmpl-' + Date.now(),
object: 'chat.completion.chunk',
created: Date.now(),
model: responsesConfig.model,
choices: [
{
index: 0,
delta: {
content: event.delta || ''
},
finish_reason: null
}
]
} as OpenAI.Chat.Completions.ChatCompletionChunk
}
}
}
return iterate()
}
export async function parseOpenAIResponsesCompletion(
@@ -417,11 +422,11 @@ export async function getNonStreamingOpenAIResponsesCompletion(
}
}
const openaiClient = testOptions?.apiKey
const openaiClient = await (testOptions?.apiKey
? createOpenAIProxyClient(getAiProxyBaseURL())
: testOptions?.workspace
? workspaceAIClients.createOpenaiClient(testOptions.workspace)
: workspaceAIClients.getOpenaiClient()
: workspaceAIClients.getOpenaiClient())
const response = await openaiClient.responses.create(
{
+38 -26
View File
@@ -1,7 +1,7 @@
import type { AIProvider, AIProviderModel } from '$lib/gen'
import { workspaceStore, type DBSchema, type SQLSchema } from '$lib/stores'
import type { IntrospectionQuery } from 'graphql'
import OpenAI from 'openai'
import type OpenAI from 'openai'
import type {
ChatCompletionChunk,
ChatCompletionCreateParams,
@@ -10,7 +10,7 @@ import type {
ChatCompletionMessageFunctionToolCall,
ChatCompletionMessageParam
} from 'openai/resources/index.mjs'
import Anthropic from '@anthropic-ai/sdk'
import type Anthropic from '@anthropic-ai/sdk'
import { get, type Writable } from 'svelte/store'
import { OpenAPI, ResourceService, type Script } from '../../gen'
import { EDIT_CONFIG, FIX_CONFIG, GEN_CONFIG } from './prompts'
@@ -389,8 +389,9 @@ export function getAiProxyBaseURL(workspace?: string): string {
: `${location.origin}${OpenAPI.BASE}/ai/proxy`
}
export function createOpenAIProxyClient(baseURL: string): OpenAI {
return new OpenAI({
export async function createOpenAIProxyClient(baseURL: string): Promise<OpenAI> {
const { default: OpenAIClass } = await import('openai')
return new OpenAIClass({
baseURL,
apiKey: 'fake-key',
defaultHeaders: {
@@ -400,8 +401,9 @@ export function createOpenAIProxyClient(baseURL: string): OpenAI {
})
}
export function createAnthropicProxyClient(baseURL: string): Anthropic {
return new Anthropic({
export async function createAnthropicProxyClient(baseURL: string): Promise<Anthropic> {
const { default: AnthropicClass } = await import('@anthropic-ai/sdk')
return new AnthropicClass({
baseURL,
apiKey: 'fake-key',
dangerouslyAllowBrowser: true,
@@ -410,34 +412,38 @@ export function createAnthropicProxyClient(baseURL: string): Anthropic {
}
class WorkspacedAIClients {
private openaiClient: OpenAI | undefined
private anthropicClient: Anthropic | undefined
private baseURL: string | undefined
private openaiClient: Promise<OpenAI> | undefined
private anthropicClient: Promise<Anthropic> | undefined
init(workspace: string) {
this.openaiClient = this.createOpenaiClient(workspace)
this.anthropicClient = this.createAnthropicClient(workspace)
this.baseURL = getAiProxyBaseURL(workspace)
// Invalidate cached clients; they will be constructed on first use,
// so the SDK chunks only load when Copilot is actually invoked.
this.openaiClient = undefined
this.anthropicClient = undefined
}
createOpenaiClient(workspace: string): OpenAI {
createOpenaiClient(workspace: string): Promise<OpenAI> {
return createOpenAIProxyClient(getAiProxyBaseURL(workspace))
}
createAnthropicClient(workspace: string): Anthropic {
createAnthropicClient(workspace: string): Promise<Anthropic> {
return createAnthropicProxyClient(getAiProxyBaseURL(workspace))
}
getOpenaiClient() {
if (!this.openaiClient) {
getOpenaiClient(): Promise<OpenAI> {
if (!this.baseURL) {
throw new Error('OpenAI not initialized')
}
return this.openaiClient
return (this.openaiClient ??= createOpenAIProxyClient(this.baseURL))
}
getAnthropicClient() {
if (!this.anthropicClient) {
getAnthropicClient(): Promise<Anthropic> {
if (!this.baseURL) {
throw new Error('Anthropic not initialized')
}
return this.anthropicClient
return (this.anthropicClient ??= createAnthropicProxyClient(this.baseURL))
}
}
@@ -522,11 +528,11 @@ async function testAnthropicKey({
headers['X-API-Key'] = apiKey
}
const anthropicClient = apiKey
const anthropicClient = await (apiKey
? createAnthropicProxyClient(getAiProxyBaseURL())
: workspace
? workspaceAIClients.createAnthropicClient(workspace)
: workspaceAIClients.getAnthropicClient()
: workspaceAIClients.getAnthropicClient())
await anthropicClient.messages.create(
{
@@ -800,11 +806,11 @@ export async function getNonStreamingCompletion(
'X-API-Key': testOptions.apiKey
}
}
const openaiClient = testOptions?.apiKey
const openaiClient = await (testOptions?.apiKey
? createOpenAIProxyClient(getAiProxyBaseURL())
: testOptions?.workspace
? workspaceAIClients.createOpenaiClient(testOptions.workspace)
: workspaceAIClients.getOpenaiClient()
: workspaceAIClients.getOpenaiClient())
const completion = await openaiClient.chat.completions.create(config, fetchOptions)
response = completion.choices?.[0]?.message.content || ''
@@ -890,7 +896,7 @@ export async function getCompletion(
options?: {
forceCompletions?: boolean
forceModelProvider?: AIProviderModel
openaiClient?: OpenAI
openaiClient?: OpenAI | Promise<OpenAI>
}
): Promise<Stream<ChatCompletionChunk>> {
const { provider, config } = getProviderAndCompletionConfig({
@@ -900,10 +906,16 @@ export async function getCompletion(
forceModelProvider: options?.forceModelProvider
})
// Use Responses API for OpenAI and Azure OpenAI
// Use Responses API for OpenAI and Azure OpenAI. Setup failures (dynamic
// SDK import, client construction, initial request) now surface here and
// fall through to the Completions API below.
if ((provider === 'openai' || provider === 'azure_openai') && !options?.forceCompletions) {
try {
const stream = getOpenAIResponsesCompletionStream(messages, abortController, tools) as any
const stream = (await getOpenAIResponsesCompletionStream(
messages,
abortController,
tools
)) as any
return stream
} catch (error) {
console.error('Error using Responses API:', error)
@@ -911,7 +923,7 @@ export async function getCompletion(
}
// Use Completions API for other providers
const client = options?.openaiClient ?? workspaceAIClients.getOpenaiClient()
const client = await (options?.openaiClient ?? workspaceAIClients.getOpenaiClient())
const completionConfig =
(provider === 'openai' || provider === 'azure_openai' || provider === 'googleai') &&
config.stream