diff --git a/extensions/cli/package-lock.json b/extensions/cli/package-lock.json index 0e6a44eec36..34099702724 100644 --- a/extensions/cli/package-lock.json +++ b/extensions/cli/package-lock.json @@ -9,7 +9,9 @@ "version": "0.0.0-dev", "license": "Apache-2.0", "dependencies": { + "@raindrop-ai/ai-sdk": "^0.0.15", "@sentry/profiling-node": "^9.47.1", + "ai": "^6.0.86", "fdir": "^6.4.2", "find-up": "^8.0.0", "fzf": "^0.5.2", @@ -121,8 +123,8 @@ "dependencies": { "@anthropic-ai/sdk": "^0.62.0", "@aws-sdk/client-bedrock-runtime": "^3.931.0", - "@aws-sdk/client-sagemaker-runtime": "^3.777.0", - "@aws-sdk/credential-providers": "^3.931.0", + "@aws-sdk/client-sagemaker-runtime": "^3.894.0", + "@aws-sdk/credential-providers": "^3.974.0", "@continuedev/config-types": "^1.0.14", "@continuedev/config-yaml": "file:../packages/config-yaml", "@continuedev/fetch": "file:../packages/fetch", @@ -201,6 +203,7 @@ "@babel/preset-env": "^7.24.7", "@biomejs/biome": "1.6.4", "@google/generative-ai": "^0.11.4", + "@modelcontextprotocol/ext-apps": "^1.0.1", "@shikijs/colorized-brackets": "^3.7.0", "@shikijs/transformers": "^3.7.0", "@types/diff": "^7.0.1", @@ -272,16 +275,18 @@ "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/anthropic": "^1.0.10", - "@ai-sdk/openai": "^1.0.10", + "@ai-sdk/anthropic": "^3.0.44", + "@ai-sdk/deepseek": "^2.0.20", + "@ai-sdk/openai": "^3.0.29", + "@ai-sdk/xai": "^3.0.57", "@anthropic-ai/sdk": "^0.67.0", "@aws-sdk/client-bedrock-runtime": "^3.931.0", - "@aws-sdk/credential-providers": "^3.931.0", + "@aws-sdk/credential-providers": "^3.974.0", "@continuedev/config-types": "^1.0.14", "@continuedev/config-yaml": "^1.38.0", "@continuedev/fetch": "^1.6.0", "@google/genai": "^1.30.0", - "ai": "^4.0.33", + "ai": "^6.0.86", "dotenv": "^16.5.0", "google-auth-library": "^10.4.1", "json-schema": "^0.4.0", @@ -323,6 +328,52 @@ "vitest": "^3.2.4" } }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.59", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.59.tgz", + "integrity": "sha512-MbtheWHgEFV/8HL1Z6E3hOAsmP73zZlNFg0F0nJAD0Adnjp4J/plqNK00Y896d+dWTw+r0OXzyov9/2wCFjH0Q==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.16", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.16.tgz", + "integrity": "sha512-kBvDqNkt5EwlzF9FujmNhhtl8FYg3e8FO8P5uneKliqfRThWemzBj+wfYr7ZCymAQhTRnwSSz1/SOqhOAwmx9g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@alcalzone/ansi-tokenize": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.0.tgz", @@ -3962,6 +4013,14 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@raindrop-ai/ai-sdk": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/@raindrop-ai/ai-sdk/-/ai-sdk-0.0.15.tgz", + "integrity": "sha512-X0lZFrvnOIYTPE+dVk08dwOnTLrY/FZL4FhCYVHptLVKdudFb+I4LSAlksA+xbDrBDesxRQ1DBiV8rhxDSY1dQ==", + "peerDependencies": { + "ai": ">=4 <7" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.47.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.47.1.tgz", @@ -4997,6 +5056,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -6103,6 +6168,15 @@ "win32" ] }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz", + "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitest/expect": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", @@ -6365,6 +6439,24 @@ "node": ">=8" } }, + "node_modules/ai": { + "version": "6.0.105", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.105.tgz", + "integrity": "sha512-rp+exWtZS3J0DDvZIfetpKCIg7D3cCsvBPoFN3I67IDTs9aoBZDbpecoIkmNLT+U9RBkoEial3OGHRvme23HCw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.59", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.16", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -9478,13 +9570,12 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.5.tgz", - "integrity": "sha512-bSRG85ZrMdmWtm7qkF9He9TNRzc/Bm99gEJMaQoHJ9E6Kv9QBbsldh2oMj7iXmYNEAVvNgvv5vPorG6W+XtBhQ==", - "dev": true, + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=18.0.0" } }, "node_modules/execa": { @@ -11832,6 +11923,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -19815,7 +19912,6 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/extensions/cli/package.json b/extensions/cli/package.json index 59a37146078..02967f5c03f 100644 --- a/extensions/cli/package.json +++ b/extensions/cli/package.json @@ -45,6 +45,8 @@ }, "homepage": "https://continue.dev", "dependencies": { + "@raindrop-ai/ai-sdk": "^0.0.15", + "ai": "^6.0.86", "@sentry/profiling-node": "^9.47.1", "fdir": "^6.4.2", "find-up": "^8.0.0", diff --git a/extensions/cli/src/services/index.ts b/extensions/cli/src/services/index.ts index ffb7cba2ca2..e9dca254ac8 100644 --- a/extensions/cli/src/services/index.ts +++ b/extensions/cli/src/services/index.ts @@ -332,6 +332,10 @@ export async function initializeServices(initOptions: ServiceInitOptions = {}) { [], // No dependencies ); + // Initialize Raindrop observability before services create LLM API instances + const raindropService = await import("../telemetry/raindropService.js"); + await raindropService.initialize(); + // Eagerly initialize all services to ensure they're ready when needed // This avoids race conditions and "service not ready" errors await serviceContainer.initializeAll(); diff --git a/extensions/cli/src/smoke-api/headless-all-tools.test.ts b/extensions/cli/src/smoke-api/headless-all-tools.test.ts index 9c919590f0f..2fd5efb3d86 100644 --- a/extensions/cli/src/smoke-api/headless-all-tools.test.ts +++ b/extensions/cli/src/smoke-api/headless-all-tools.test.ts @@ -8,7 +8,7 @@ import { createSmokeContext, cleanupSmokeContext, writeAnthropicConfig, - runHeadless, + runHeadlessWithRetry, type SmokeTestContext, } from "./smoke-api-helpers.js"; @@ -62,7 +62,7 @@ describe.skipIf(!ANTHROPIC_API_KEY)( After all tools complete, say exactly "ALL_TOOLS_DONE" as your final message.`; it("should exercise 10 built-in tools and complete successfully", async () => { - const result = await runHeadless( + const result = await runHeadlessWithRetry( ctx, ["-p", "--auto", "--config", ctx.configPath, PROMPT], { timeout: 120000 }, diff --git a/extensions/cli/src/smoke-api/headless-anthropic.test.ts b/extensions/cli/src/smoke-api/headless-anthropic.test.ts index 571e43e097b..970659d0092 100644 --- a/extensions/cli/src/smoke-api/headless-anthropic.test.ts +++ b/extensions/cli/src/smoke-api/headless-anthropic.test.ts @@ -4,7 +4,7 @@ import { createSmokeContext, cleanupSmokeContext, writeAnthropicConfig, - runHeadless, + runHeadlessWithRetry, type SmokeTestContext, } from "./smoke-api-helpers.js"; @@ -25,7 +25,7 @@ describe.skipIf(!ANTHROPIC_API_KEY)( }); it("should complete a round-trip and return a response", async () => { - const result = await runHeadless(ctx, [ + const result = await runHeadlessWithRetry(ctx, [ "-p", "--config", ctx.configPath, diff --git a/extensions/cli/src/smoke-api/headless-continue-proxy.test.ts b/extensions/cli/src/smoke-api/headless-continue-proxy.test.ts index 25a89da468e..ef86639702c 100644 --- a/extensions/cli/src/smoke-api/headless-continue-proxy.test.ts +++ b/extensions/cli/src/smoke-api/headless-continue-proxy.test.ts @@ -4,7 +4,7 @@ import { createSmokeContext, cleanupSmokeContext, writeContinueProxyConfig, - runHeadless, + runHeadlessWithRetry, type SmokeTestContext, } from "./smoke-api-helpers.js"; @@ -29,7 +29,7 @@ describe.skipIf(!CONTINUE_API_KEY || !SMOKE_PROXY_MODEL)( }); it("should complete a round-trip and return a response", async () => { - const result = await runHeadless(ctx, [ + const result = await runHeadlessWithRetry(ctx, [ "-p", "--config", ctx.configPath, diff --git a/extensions/cli/src/smoke-api/serve-anthropic.test.ts b/extensions/cli/src/smoke-api/serve-anthropic.test.ts index 1d6a0c4e9ef..788005825fe 100644 --- a/extensions/cli/src/smoke-api/serve-anthropic.test.ts +++ b/extensions/cli/src/smoke-api/serve-anthropic.test.ts @@ -7,7 +7,8 @@ import { writeAnthropicConfig, spawnServe, waitForPattern, - pollUntilIdle, + sendMessageAndWait, + getLastAssistantContent, shutdownServe, type SmokeTestContext, } from "./smoke-api-helpers.js"; @@ -45,37 +46,14 @@ describe.skipIf(!ANTHROPIC_API_KEY)( // Wait for the server to start await waitForPattern(proc, "Server started", 30000); - // Send a message - const msgRes = await fetch(`${baseUrl}/message`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - message: "Reply with exactly the word 'hello' and nothing else.", - }), - }); - expect(msgRes.ok).toBe(true); - - // Poll until the agent finishes processing - const state = await pollUntilIdle(baseUrl, 60000); - - // State shape: { session: { history: ChatHistoryItem[] }, ... } - // Each ChatHistoryItem has { message: { role, content }, ... } - const history: any[] = state.session?.history ?? []; - const assistantItems = history.filter( - (item: any) => item.message?.role === "assistant", + // Send a message (retries with backoff on rate-limit errors) + const state = await sendMessageAndWait( + baseUrl, + "Reply with exactly the word 'hello' and nothing else.", ); - expect(assistantItems.length).toBeGreaterThan(0); - - const lastMsg = assistantItems[assistantItems.length - 1].message; - const content = - typeof lastMsg.content === "string" - ? lastMsg.content - : lastMsg.content - ?.filter((p: any) => p.type === "text") - .map((p: any) => p.text) - .join(""); - expect(content?.toLowerCase()).toContain("hello"); + const content = getLastAssistantContent(state); + expect(content).toContain("hello"); // Graceful exit const exitRes = await fetch(`${baseUrl}/exit`, { method: "POST" }); diff --git a/extensions/cli/src/smoke-api/smoke-api-helpers.ts b/extensions/cli/src/smoke-api/smoke-api-helpers.ts index ee2dcc922d7..ee8ba3fd1ad 100644 --- a/extensions/cli/src/smoke-api/smoke-api-helpers.ts +++ b/extensions/cli/src/smoke-api/smoke-api-helpers.ts @@ -140,6 +140,44 @@ export async function runHeadless( }; } +/** + * Runs headless with retry + exponential backoff for rate-limit errors. + * If stdout/stderr indicates a rate limit, waits and retries. + */ +export async function runHeadlessWithRetry( + ctx: SmokeTestContext, + args: string[], + opts: { + timeout?: number; + env?: Record; + maxRetries?: number; + } = {}, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const { maxRetries = 3, ...runOpts } = opts; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const result = await runHeadless(ctx, args, runOpts); + + const combined = (result.stdout + result.stderr).toLowerCase(); + const isRateLimited = + combined.includes("rate_limit") || + combined.includes("overloaded") || + combined.includes("529") || + combined.includes("too many requests"); + + if (!isRateLimited || attempt === maxRetries) { + return result; + } + + // Wait with exponential backoff: 10s, 20s, 40s + const delay = Math.min(10000 * 2 ** attempt, 60000); + await new Promise((r) => setTimeout(r, delay)); + } + + // Unreachable, but TypeScript needs it + throw new Error("runHeadlessWithRetry: unexpected code path"); +} + /** * Spawns `cn serve` as a background subprocess and returns it along with * a helper to wait for the server to be ready. @@ -252,6 +290,91 @@ export async function pollUntilIdle( throw new Error(`pollUntilIdle timed out after ${timeout}ms`); } +/** + * Extracts the last assistant message content as a lowercase string from state. + */ +export function getLastAssistantContent(state: any): string { + const history: any[] = state.session?.history ?? []; + const assistantItems = history.filter( + (item: any) => item.message?.role === "assistant", + ); + if (assistantItems.length === 0) { + return ""; + } + const lastMsg = assistantItems[assistantItems.length - 1].message; + const content = + typeof lastMsg.content === "string" + ? lastMsg.content + : lastMsg.content + ?.filter((p: any) => p.type === "text") + .map((p: any) => p.text) + .join(""); + return content?.toLowerCase() ?? ""; +} + +/** + * Returns true if the state contains evidence of a rate-limit or overloaded + * error from the provider (in any message, not just the last one). + */ +function hasRateLimitError(state: any): boolean { + const history: any[] = state.session?.history ?? []; + return history.some((item: any) => { + const c = item.message?.content; + const text = + typeof c === "string" + ? c + : Array.isArray(c) + ? c + .filter((p: any) => p.type === "text") + .map((p: any) => p.text) + .join("") + : ""; + return ( + text.includes("rate_limit") || + text.includes("overloaded") || + text.includes("529") || + text.includes("Too many requests") + ); + }); +} + +/** + * Sends a message to a serve instance, polls until idle, and returns the state. + * If the response indicates a rate-limit error, retries with exponential + * backoff (up to maxRetries times). + */ +export async function sendMessageAndWait( + baseUrl: string, + message: string, + opts: { maxRetries?: number; pollTimeout?: number } = {}, +): Promise { + const { maxRetries = 3, pollTimeout = 60000 } = opts; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const msgRes = await fetch(`${baseUrl}/message`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message }), + }); + + if (!msgRes.ok) { + throw new Error( + `POST /message failed: ${msgRes.status} ${msgRes.statusText}`, + ); + } + + const state = await pollUntilIdle(baseUrl, pollTimeout); + + if (!hasRateLimitError(state) || attempt === maxRetries) { + return state; + } + + // Wait with exponential backoff: 10s, 20s, 40s + const delay = Math.min(10000 * 2 ** attempt, 60000); + await new Promise((r) => setTimeout(r, delay)); + } +} + /** * Gracefully shuts down a serve subprocess via POST /exit, then kills it. */ diff --git a/extensions/cli/src/stream/streamChatResponse.helpers.ts b/extensions/cli/src/stream/streamChatResponse.helpers.ts index 6f8f3fa18de..03008b95ea3 100644 --- a/extensions/cli/src/stream/streamChatResponse.helpers.ts +++ b/extensions/cli/src/stream/streamChatResponse.helpers.ts @@ -366,59 +366,55 @@ export function recordStreamTelemetry(options: { costUsd: cost, }); - // Mirror core metrics to PostHog for product analytics + reportToPostHog({ + model: model.model, + totalDuration, + actualInputTokens, + actualOutputTokens, + cost, + fullUsage, + toolCount: tools?.length ?? 0, + }); + + return cost; +} + +function reportToPostHog(opts: { + model: string; + totalDuration: number; + actualInputTokens: number; + actualOutputTokens: number; + cost: number; + fullUsage?: any; + toolCount: number; +}): void { const cacheReadTokens = - fullUsage?.prompt_tokens_details?.cache_read_tokens ?? 0; + opts.fullUsage?.prompt_tokens_details?.cache_read_tokens ?? 0; const cacheWriteTokens = - fullUsage?.prompt_tokens_details?.cache_write_tokens ?? 0; + opts.fullUsage?.prompt_tokens_details?.cache_write_tokens ?? 0; try { posthogService.capture("apiRequest", { - model: model.model, - durationMs: totalDuration, - inputTokens: actualInputTokens, - outputTokens: actualOutputTokens, - costUsd: cost, + model: opts.model, + durationMs: opts.totalDuration, + inputTokens: opts.actualInputTokens, + outputTokens: opts.actualOutputTokens, + costUsd: opts.cost, cacheReadTokens, cacheWriteTokens, }); - // Emit prompt_cache_metrics for the Prompt Cache Performance dashboard - if (actualInputTokens > 0) { + if (opts.actualInputTokens > 0) { posthogService.capture("prompt_cache_metrics", { - model: model.model, + model: opts.model, cache_read_tokens: cacheReadTokens, cache_write_tokens: cacheWriteTokens, - total_prompt_tokens: actualInputTokens, - cache_hit_rate: cacheReadTokens / actualInputTokens, - tool_count: tools?.length ?? 0, + total_prompt_tokens: opts.actualInputTokens, + cache_hit_rate: cacheReadTokens / opts.actualInputTokens, + tool_count: opts.toolCount, }); } } catch {} - - // Report prompt cache metrics to PostHog - if (fullUsage?.prompt_tokens_details) { - const cacheReadTokens = - fullUsage.prompt_tokens_details.cache_read_tokens ?? 0; - const cacheWriteTokens = - fullUsage.prompt_tokens_details.cache_write_tokens ?? 0; - const totalPromptTokens = fullUsage.prompt_tokens ?? 0; - const cacheHitRate = - totalPromptTokens > 0 ? cacheReadTokens / totalPromptTokens : 0; - - try { - void posthogService.capture("prompt_cache_metrics", { - model: model.model, - cache_read_tokens: cacheReadTokens, - cache_write_tokens: cacheWriteTokens, - total_prompt_tokens: totalPromptTokens, - cache_hit_rate: cacheHitRate, - tool_count: tools?.length ?? 0, - }); - } catch {} - } - - return cost; } /** diff --git a/extensions/cli/src/telemetry/raindropService.ts b/extensions/cli/src/telemetry/raindropService.ts new file mode 100644 index 00000000000..3e83b0ca252 --- /dev/null +++ b/extensions/cli/src/telemetry/raindropService.ts @@ -0,0 +1,56 @@ +import { createHash } from "crypto"; + +import { logger } from "../util/logger.js"; + +let raindropClient: any = null; + +function getAnonymousUserId(): string { + const raw = process.env.USER ?? process.env.USERNAME ?? "unknown"; + return createHash("sha256").update(raw).digest("hex").slice(0, 16); +} + +export async function initialize(): Promise { + const writeKey = process.env.RAINDROP_WRITE_KEY; + if (!writeKey) { + return; + } + + try { + const { createRaindropAISDK } = await import("@raindrop-ai/ai-sdk"); + const ai = await import("ai"); + const { setAiModuleOverride } = await import( + "@continuedev/openai-adapters" + ); + + raindropClient = createRaindropAISDK({ writeKey }); + + const wrapped = raindropClient.wrap(ai, { + context: { + userId: getAnonymousUserId(), + eventName: "cli-completion", + }, + }); + + // Merge wrapped functions over the original ai module + setAiModuleOverride({ ...ai, ...wrapped }); + + // Only route through AI SDK path after successful init + process.env.CONTINUE_USE_AI_SDK = "true"; + + logger.debug("Raindrop observability initialized"); + } catch (err) { + logger.debug("Raindrop initialization failed (non-critical)", err as any); + } +} + +export async function shutdown(): Promise { + if (!raindropClient) { + return; + } + + try { + await raindropClient.shutdown(); + } catch (err) { + logger.debug("Raindrop shutdown error (ignored)", err as any); + } +} diff --git a/extensions/cli/src/util/exit.ts b/extensions/cli/src/util/exit.ts index 17012d8228e..beef305666c 100644 --- a/extensions/cli/src/util/exit.ts +++ b/extensions/cli/src/util/exit.ts @@ -212,6 +212,13 @@ export async function gracefulExit(code: number = 0): Promise { logger.debug("Telemetry shutdown error (ignored)", err as any); } + try { + const raindropService = await import("../telemetry/raindropService.js"); + await raindropService.shutdown(); + } catch (err) { + logger.debug("Raindrop shutdown error (ignored)", err as any); + } + try { // Flush Sentry (best effort) await sentryService.flush(); diff --git a/packages/openai-adapters/src/apis/AiSdk.ts b/packages/openai-adapters/src/apis/AiSdk.ts index 6edfbbe4152..692ab73cef5 100644 --- a/packages/openai-adapters/src/apis/AiSdk.ts +++ b/packages/openai-adapters/src/apis/AiSdk.ts @@ -41,6 +41,16 @@ const PROVIDER_MAP: Record = { }), }; +let aiModuleOverride: any = null; + +export function setAiModuleOverride(mod: any) { + aiModuleOverride = mod; +} + +async function getAiModule(): Promise { + return aiModuleOverride ?? (await import("ai")); +} + export class AiSdkApi implements BaseLlmApi { private provider?: (modelId: string) => any; private config: AiSdkConfig; @@ -97,7 +107,7 @@ export class AiSdkApi implements BaseLlmApi { ): Promise { this.initializeProvider(); - const { generateText } = await import("ai"); + const { generateText } = await getAiModule(); const { convertOpenAIMessagesToVercel } = await import( "../openaiToVercelMessages.js" ); @@ -182,7 +192,7 @@ export class AiSdkApi implements BaseLlmApi { ): AsyncGenerator { this.initializeProvider(); - const { streamText } = await import("ai"); + const { streamText } = await getAiModule(); const { convertOpenAIMessagesToVercel } = await import( "../openaiToVercelMessages.js" ); @@ -258,7 +268,7 @@ export class AiSdkApi implements BaseLlmApi { async embed(body: EmbeddingCreateParams): Promise { this.initializeProvider(); - const { embed: aiEmbed, embedMany } = await import("ai"); + const { embed: aiEmbed, embedMany } = await getAiModule(); const modelId = typeof body.model === "string" ? body.model : body.model; const model = this.provider!(modelId); diff --git a/packages/openai-adapters/src/index.ts b/packages/openai-adapters/src/index.ts index a00b95b81f7..a92558f6e75 100644 --- a/packages/openai-adapters/src/index.ts +++ b/packages/openai-adapters/src/index.ts @@ -215,7 +215,7 @@ export { } from "openai/resources/index"; // export -export { AiSdkApi } from "./apis/AiSdk.js"; +export { AiSdkApi, setAiModuleOverride } from "./apis/AiSdk.js"; export type { BaseLlmApi } from "./apis/base.js"; export type { AiSdkConfig,