From 87991f9e7537695b1ed9bb52d8ce3bd8a14d9752 Mon Sep 17 00:00:00 2001 From: Daniel Campagnoli Date: Sat, 18 Oct 2025 02:41:51 +0800 Subject: [PATCH 1/2] misc tidy up updates --- bin/aider | 20 ----- bin/path/taider | 2 +- bin/post_install | 1 + docs/docs/about.md | 2 - docs/docs/authentication.md | 12 --- docs/docs/contributing.md | 2 - frontend/bin/find-problematic-test | 24 ++++++ package.json | 5 +- pnpm-lock.yaml | 35 ++++---- src/cli/mcp.ts | 24 ++++++ .../functionSchemaToJsonSchema.test.ts} | 0 .../functionSchemaToJsonSchema.ts | 0 src/functions/cloud/google/google-cloud.ts | 79 +++++++++++++++--- src/llm/llmFactory.ts | 2 +- src/llm/multi-agent/cerebrasFallback.ts | 56 +++++++++++++ src/llm/services/cerebras-openrouter.ts | 80 +++++++++++++++++++ src/llm/services/llm.int.ts | 2 +- src/llm/services/openrouter.ts | 16 +--- src/routes/DOCS.md | 4 + 19 files changed, 283 insertions(+), 83 deletions(-) delete mode 100755 bin/aider create mode 100644 bin/post_install delete mode 100644 docs/docs/about.md delete mode 100644 docs/docs/authentication.md delete mode 100644 docs/docs/contributing.md create mode 100755 frontend/bin/find-problematic-test create mode 100644 src/cli/mcp.ts rename src/{routes/webhooks/gitlab/gitlabNoteHandler.test.ts => functionSchema/functionSchemaToJsonSchema.test.ts} (100%) create mode 100644 src/functionSchema/functionSchemaToJsonSchema.ts create mode 100644 src/llm/multi-agent/cerebrasFallback.ts create mode 100644 src/llm/services/cerebras-openrouter.ts diff --git a/bin/aider b/bin/aider deleted file mode 100755 index a00230b45..000000000 --- a/bin/aider +++ /dev/null @@ -1,20 +0,0 @@ -# Convenience script for running Aider -source ./variables/local.env - -export VERTEXAI_PROJECT=$GCLOUD_PROJECT -export VERTEXAI_LOCATION=$GCLOUD_REGION -export OPENAI_API_KEY=$OPENAI_API_KEY -export DEEPSEEK_API_KEY=$DEEPSEEK_API_KEY -export OPENROUTER_API_KEY=$OPENROUTER_API_KEY -export GEMINI_API_KEY=$GEMINI_API_KEY -export XAI_API_KEY=$XAI_API_KEY - -MODEL=vertex_ai/gemini-2.5-pro -#MODEL=o3 -#MODEL=xai/grok-4 -EDITOR_MODEL=vertex_ai/gemini-2.5-flash -WEAK_MODEL=vertex_ai/gemini-2.5-flash - -echo $MODEL $EDITOR_MODEL $VERTEXAI_PROJECT $VERTEXAI_LOCATION - -aider --model $MODEL --editor-model $EDITOR_MODEL --weak-model $WEAK_MODEL --no-auto-accept-architect --test-cmd "npm run test && cd frontend && npm run build" --auto-test diff --git a/bin/path/taider b/bin/path/taider index f0b756dfa..2ec93575f 100755 --- a/bin/path/taider +++ b/bin/path/taider @@ -10,7 +10,7 @@ export OPENROUTER_API_KEY=$OPENROUTER_API_KEY export GEMINI_API_KEY=$GEMINI_API_KEY MODEL=gpt-5 -#MODEL= +MODEL=openrouter/openai/gpt-5 EDITOR_MODEL=vertex_ai/gemini-2.5-flash WEAK_MODEL=vertex_ai/gemini-2.5-flash diff --git a/bin/post_install b/bin/post_install new file mode 100644 index 000000000..93aea4222 --- /dev/null +++ b/bin/post_install @@ -0,0 +1 @@ +# TODO: Check front-end and backend ai and @sinclair/typebox versions in package-lock.json are identical diff --git a/docs/docs/about.md b/docs/docs/about.md deleted file mode 100644 index b1968b0a1..000000000 --- a/docs/docs/about.md +++ /dev/null @@ -1,2 +0,0 @@ - -# Project \ No newline at end of file diff --git a/docs/docs/authentication.md b/docs/docs/authentication.md deleted file mode 100644 index ed0e41f87..000000000 --- a/docs/docs/authentication.md +++ /dev/null @@ -1,12 +0,0 @@ -# Authentication - -The authentication mode is defined by the `AUTH` environment variable which is set in the `variables/.env` file. - -Currently, the valid options are `single_user` and `IAP` - -## Single user mode - -By default, TypedAI runs in a single user mode which disables any authentication. On startup the database is queried for -a user profile, and if none is found then creates one, using the email from the `SINGLE_USER_EMAIL` environment variable. - -Th \ No newline at end of file diff --git a/docs/docs/contributing.md b/docs/docs/contributing.md deleted file mode 100644 index 8ba1f31cb..000000000 --- a/docs/docs/contributing.md +++ /dev/null @@ -1,2 +0,0 @@ -# Contributing - diff --git a/frontend/bin/find-problematic-test b/frontend/bin/find-problematic-test new file mode 100755 index 000000000..596dff7ac --- /dev/null +++ b/frontend/bin/find-problematic-test @@ -0,0 +1,24 @@ +#!/bin/bash + +# Save this script as find-problematic-test.sh +# Make it executable: chmod +x find-problematic-test.sh + +# Find all spec files +SPEC_FILES=$(find src -name "*.spec.ts") + +echo "Found $(echo "$SPEC_FILES" | wc -l) spec files" +echo "" + +# Run each spec file individually +for spec in $SPEC_FILES; do + echo "Testing: $spec" + npm run env:test && ng test --include="$spec" --browsers=ChromeHeadless --watch=false + + # Check if the test failed with non-zero exit code + if [ $? -ne 0 ]; then + echo "FOUND PROBLEMATIC TEST: $spec" + break + fi + + echo "------------------------------------" +done \ No newline at end of file diff --git a/package.json b/package.json index 588536c44..b8e11d1e8 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "@grpc/grpc-js": "^1.13.4", "@microsoft/tiktokenizer": "^1.0.10", "@mistralai/mistralai": "^1.7.1", - "@modelcontextprotocol/sdk": "^1.18.0", + "@modelcontextprotocol/sdk": "1.20.0", "@mozilla/readability": "^0.6.0", "@octokit/request": "^5.1.0", "@openrouter/ai-sdk-provider": "1.1.2", @@ -180,7 +180,8 @@ "tslib": "^2.6.2", "turndown": "^7.1.3", "uuid": "^9.0.1", - "xmldom": "^0.6.0" + "xmldom": "^0.6.0", + "yaml": "^2.8.1" }, "devDependencies": { "@biomejs/biome": "1.9.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d33240276..fcf2aca1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,8 +114,8 @@ importers: specifier: ^1.7.1 version: 1.7.5(zod@3.25.76) '@modelcontextprotocol/sdk': - specifier: ^1.18.0 - version: 1.18.0 + specifier: 1.20.0 + version: 1.20.0 '@mozilla/readability': specifier: ^0.6.0 version: 0.6.0 @@ -347,6 +347,9 @@ importers: xmldom: specifier: ^0.6.0 version: 0.6.0 + yaml: + specifier: ^2.8.1 + version: 2.8.1 devDependencies: '@biomejs/biome': specifier: 1.9.4 @@ -1778,8 +1781,8 @@ packages: '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} - '@modelcontextprotocol/sdk@1.18.0': - resolution: {integrity: sha512-JvKyB6YwS3quM+88JPR0axeRgvdDu3Pv6mdZUy+w4qVkCzGgumb9bXG/TmtDRQv+671yaofVfXSQmFLlWU5qPQ==} + '@modelcontextprotocol/sdk@1.20.0': + resolution: {integrity: sha512-kOQ4+fHuT4KbR2iq2IjeV32HiihueuOf1vJkq18z08CLZ1UQrTc8BXJpVfxZkq45+inLLD+D4xx4nBjUelJa4Q==} engines: {node: '>=18'} '@mongodb-js/saslprep@1.3.0': @@ -4419,10 +4422,6 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} - eventsource-parser@3.0.3: - resolution: {integrity: sha512-nVpZkTMM9rF6AQ9gPJpFsNAMt48wIzB5TQgiTLdHiuO8XEDhUgZEhqKlZWXbIzo9VmJ/HvysHqEaVeD5v9TPvA==} - engines: {node: '>=20.0.0'} - eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -8329,8 +8328,8 @@ packages: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} - yaml@2.8.0: - resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} engines: {node: '>= 14.6'} hasBin: true @@ -9896,14 +9895,14 @@ snapshots: '@mixmark-io/domino@2.2.0': {} - '@modelcontextprotocol/sdk@1.18.0': + '@modelcontextprotocol/sdk@1.20.0': dependencies: ajv: 6.12.6 content-type: 1.0.5 cors: 2.8.5 cross-spawn: 7.0.6 eventsource: 3.0.7 - eventsource-parser: 3.0.3 + eventsource-parser: 3.0.6 express: 5.1.0 express-rate-limit: 7.5.1(express@5.1.0) pkce-challenge: 5.0.0 @@ -13123,13 +13122,11 @@ snapshots: events@3.3.0: {} - eventsource-parser@3.0.3: {} - eventsource-parser@3.0.6: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.0.3 + eventsource-parser: 3.0.6 execa@5.1.1: dependencies: @@ -13630,7 +13627,7 @@ snapshots: winston: 3.17.0 winston-transport: 4.9.0 ws: 7.5.10 - yaml: 2.8.0 + yaml: 2.8.1 transitivePeerDependencies: - bufferutil - encoding @@ -14881,7 +14878,7 @@ snapshots: micromatch: 4.0.8 pidtree: 0.6.0 string-argv: 0.3.2 - yaml: 2.8.0 + yaml: 2.8.1 transitivePeerDependencies: - supports-color @@ -15599,7 +15596,7 @@ snapshots: openapi3-ts@3.2.0: dependencies: - yaml: 2.8.0 + yaml: 2.8.1 optionator@0.8.3: dependencies: @@ -17742,7 +17739,7 @@ snapshots: yaml@1.10.2: {} - yaml@2.8.0: {} + yaml@2.8.1: {} yargonaut@1.1.4: dependencies: diff --git a/src/cli/mcp.ts b/src/cli/mcp.ts new file mode 100644 index 000000000..424ef7428 --- /dev/null +++ b/src/cli/mcp.ts @@ -0,0 +1,24 @@ +import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; + +const server = new McpServer({ + name: 'tpyedai-server', + version: '1.0.0', +}); + +// server.registerTool( +// 'localSearch', +// { +// title: 'Fast search', +// description: 'Performans a fast local search of the codebase', +// inputSchema: { workingDirectory: z.string(), query: z.string() }, +// outputSchema: { result: z.string(), files: z.array(z.string()) } +// }, +// async ({ workingDirectory, query }) => { +// const output = { result: a + b }; +// return { +// content: [{ type: 'text', text: JSON.stringify(output) }], +// structuredContent: output +// }; +// } +// ); diff --git a/src/routes/webhooks/gitlab/gitlabNoteHandler.test.ts b/src/functionSchema/functionSchemaToJsonSchema.test.ts similarity index 100% rename from src/routes/webhooks/gitlab/gitlabNoteHandler.test.ts rename to src/functionSchema/functionSchemaToJsonSchema.test.ts diff --git a/src/functionSchema/functionSchemaToJsonSchema.ts b/src/functionSchema/functionSchemaToJsonSchema.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/functions/cloud/google/google-cloud.ts b/src/functions/cloud/google/google-cloud.ts index 247ab943f..ce279b4a6 100644 --- a/src/functions/cloud/google/google-cloud.ts +++ b/src/functions/cloud/google/google-cloud.ts @@ -1,23 +1,82 @@ +import { GoogleAuth } from 'google-auth-library'; +import { stringify as yamlStringify } from 'yaml'; import { agentContext } from '#agent/agentContextLocalStorage'; import { humanInTheLoop } from '#agent/autonomous/humanInTheLoop'; import { func, funcClass } from '#functionSchema/functionDecorators'; +import { extractCommonProperties } from '#utils/arrayUtils'; import { execCommand, failOnError } from '#utils/exec'; - @funcClass(__filename) export class GoogleCloud { /** - * Gets the logs from Google Cloud Logging - * @param gcpProjectId The Google Cloud projectId - * @param filter The Cloud Logging filter to search - * @param dateFromIso The date/time to get the logs from - * @param dateToIso The date/time to get the logs to, or empty if upto now. + * Gets the logs from Google Cloud Logging. + * Either provide freshness or provide dateFromIso and/or dateToIso. + * The results will be limited to 1000 results. Make further calls adjusting the time options to get more results. + * @param {string} projectId - The Google Cloud projectId + * @param {string} filter - The Cloud Logging filter to search (e.g. "resource.type=cloud_run_revision" and "resource.labels.service_name=the-service-name") + * @param {Object} options - Configuration options + * @param {string} [options.dateFromIso] - The date/time to get the logs from. Optional. + * @param {string} [options.dateToIso] - The date/time to get the logs to. Optional. + * @param {string} [options.freshness] - The freshness of the logs (eg. 10m, 1h). Optional. + * @param {'asc'|'desc'} [options.order] - The order of the logs (asc or desc. defaults to desc). Optional. + * @returns {Promise} */ @func() - async getCloudLoggingLogs(gcpProjectId: string, filter: string, dateFromIso: string, dateToIso: string): Promise { - const cmd = `gcloud logging read '${filter}' --project=${gcpProjectId} --format="json" --log-filter='timestamp>="${dateFromIso}" AND timestamp<="${dateToIso}"'`; + async getCloudLoggingLogs( + projectId: string, + filter: string, + options: { dateFromIso?: string; dateToIso?: string; freshness?: string; order?: 'asc' | 'desc' }, + ): Promise { + let logFiler = filter; + if (options.dateFromIso) logFiler += ` AND timestamp>="${options.dateFromIso}"`; + if (options.dateToIso) logFiler += ` AND timestamp<="${options.dateToIso}"`; + if (options.order) logFiler += ` AND order=${options.order}`; + + let cmd = `gcloud logging read '${filter}' -q --project=${projectId} --format="json"`; + if (options.freshness) cmd += ` --freshness=${options.freshness}`; + if (options.order) cmd += ` --order=${options.order}`; + cmd += ' --limit=1000'; + const result = await execCommand(cmd); - if (result.exitCode > 0) throw new Error(`Error running '${cmd}'. ${result.stdout}${result.stderr}`); - return result.stdout; + + try { + const json = JSON.parse(result.stdout); + + if (!Array.isArray(json) || json.length === 0) return yamlStringify(json); + + // Logs for a single resource type will have common properties. Extract them out to reduce tokens returned. + const { commonProps, strippedItems } = extractCommonProperties(json); + + const hasCommonProps = Object.keys(commonProps).length > 0; + const output = hasCommonProps + ? json + : { + logCount: strippedItems.length, + commonProperties: commonProps, + logs: strippedItems, + }; + + // Return YAML by default for token efficiency + return yamlStringify(output, { indent: 2 }); + } catch (e) { + if (result.exitCode > 0) throw new Error(`Error running '${cmd}'. ${result.stdout}${result.stderr}`); + return result.stdout; + } + } + + /** + * Gets the spans from Google Cloud Trace + * @param {string} projectId - The Google Cloud projectId + * @param {string} traceId - The trace id + * @returns {Promise} the spans as a JSON string, or 'Trace Id not found' if the trace id is not found + */ + // @func() + async getTraceSpans(projectId: string, traceId: string) { + const auth = new GoogleAuth({ scopes: 'https://www.googleapis.com/auth/cloud-platform' }); + const client = await auth.getClient(); + const url = `https://cloudtrace.googleapis.com/v1/projects/${projectId}/traces/${traceId}`; + const res = await client.request({ url }); + const trace = res.data; + return trace ? JSON.stringify((trace as any).spans) : 'Trace Id not found'; } /** diff --git a/src/llm/llmFactory.ts b/src/llm/llmFactory.ts index 55b79dd89..5e2b2dee0 100644 --- a/src/llm/llmFactory.ts +++ b/src/llm/llmFactory.ts @@ -5,6 +5,7 @@ import { MultiLLM } from '#llm/multi-llm'; import { anthropicLLMRegistry } from '#llm/services/anthropic'; import { anthropicVertexLLMRegistry } from '#llm/services/anthropic-vertex'; import { cerebrasLLMRegistry } from '#llm/services/cerebras'; +import { openrouterLLMRegistry } from '#llm/services/cerebras-openrouter'; import { deepinfraLLMRegistry } from '#llm/services/deepinfra'; import { deepseekLLMRegistry } from '#llm/services/deepseek'; import { fireworksLLMRegistry } from '#llm/services/fireworks'; @@ -14,7 +15,6 @@ import { mockLLMRegistry } from '#llm/services/mock-llm'; import { nebiusLLMRegistry } from '#llm/services/nebius'; import { ollamaLLMRegistry } from '#llm/services/ollama'; import { openAiLLMRegistry } from '#llm/services/openai'; -import { openrouterLLMRegistry } from '#llm/services/openrouter'; import { perplexityLLMRegistry } from '#llm/services/perplexity-llm'; import { sambanovaLLMRegistry } from '#llm/services/sambanova'; import { togetherLLMRegistry } from '#llm/services/together'; diff --git a/src/llm/multi-agent/cerebrasFallback.ts b/src/llm/multi-agent/cerebrasFallback.ts new file mode 100644 index 000000000..6a74dd00d --- /dev/null +++ b/src/llm/multi-agent/cerebrasFallback.ts @@ -0,0 +1,56 @@ +import { cerebrasQwen3_235b_Thinking } from '#llm/services/cerebras'; +import { openRouterQwen3_235b_Thinking } from '#llm/services/cerebras-openrouter'; +import { logger } from '#o11y/logger'; +import type { GenerateTextOptions, LLM, LlmMessage } from '#shared/llm/llm.model'; +import { BaseLLM } from '../base-llm'; + +export function cerebrasFallbackRegistry(): Record LLM> { + return { + 'cerebras-fallback:qwen3-235b-thinking': cerebrasFallbackQwen3_235b_Thinking, + }; +} + +export function cerebrasFallbackQwen3_235b_Thinking(): LLM { + return new CerebrasFallback(); +} + +/** + */ +export class CerebrasFallback extends BaseLLM { + private llms: LLM[] = [cerebrasQwen3_235b_Thinking(), openRouterQwen3_235b_Thinking()]; + + constructor() { + super({ + displayName: 'Cerebras Qwen3.235b (Thinking) (OpenRouter)', + service: 'cerebras-fallback', + modelId: 'cerebras-fallback:qwen3-235b-thinking', + maxInputTokens: 0, + calculateCosts: () => ({ + inputCost: 0, + outputCost: 0, + totalCost: 0, + }), + }); + } + + protected override supportsGenerateTextFromMessages(): boolean { + return true; + } + + override isConfigured(): boolean { + return this.llms.findIndex((llm) => !llm.isConfigured()) === -1; + } + + override async generateTextFromMessages(messages: LlmMessage[], opts?: GenerateTextOptions): Promise { + for (const llm of this.llms) { + if (!llm.isConfigured()) continue; + + try { + return await llm.generateText(messages, opts); + } catch (error) { + logger.error(`Error with ${llm.getDisplayName()}: ${error.message}. Trying next provider.`); + } + } + throw new Error('All Cerebras Qwen3.235b (Thinking) providers failed.'); + } +} diff --git a/src/llm/services/cerebras-openrouter.ts b/src/llm/services/cerebras-openrouter.ts new file mode 100644 index 000000000..d03b6cc55 --- /dev/null +++ b/src/llm/services/cerebras-openrouter.ts @@ -0,0 +1,80 @@ +import { EmbeddingModelV2, ImageModelV2, LanguageModelV2 } from '@ai-sdk/provider'; +import { OpenRouterProvider, createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { costPerMilTokens } from '#llm/base-llm'; +import type { GenerateTextOptions, LLM, LlmCostFunction } from '#shared/llm/llm.model'; +import { currentUser } from '#user/userContext'; +import { AiLLM } from './ai-llm'; + +export const CEREBRAS_OPENROUTER_SERVICE = 'cerebras-openrouter'; + +export function openrouterLLMRegistry(): Record LLM> { + return { + 'cerebras-openrouter:qwen/qwen3-235b-a22b-thinking-2507': () => openRouterQwen3_235b_Thinking(), + 'cerebras-openrouter:qwen/qwen/qwen3-235b-a22b-2507': () => openRouterQwen3_235b_Instruct(), + }; +} + +// https://openrouter.ai/models + +export function openRouterQwen3_235b_Thinking(): LLM { + return new OpenRouterLLM('Qwen3 235b Thinking (Cerebras)', 'qwen/qwen3-235b-a22b-thinking-2507', 131_000, costPerMilTokens(0.6, 1.2), {}); +} + +export function openRouterQwen3_235b_Instruct(): LLM { + return new OpenRouterLLM('Qwen3 235b Instruct (Cerebras)', 'qwen/qwen3-235b-a22b-2507', 131_000, costPerMilTokens(0.6, 1.2), {}); +} + +declare module '@openrouter/ai-sdk-provider' { + interface OpenRouterProvider { + languageModel(modelId: string): LanguageModelV2; + /** + Returns the text embedding model with the given id. + The model id is then passed to the provider function to get the model. + + @param {string} modelId - The id of the model to return. + + @returns {LanguageModel} The language model associated with the id + + @throws {NoSuchModelError} If no such model exists. + */ + textEmbeddingModel(modelId: string): EmbeddingModelV2; + /** + Returns the image model with the given id. + The model id is then passed to the provider function to get the model. + + @param {string} modelId - The id of the model to return. + + @returns {ImageModel} The image model associated with the id + */ + imageModel(modelId: string): ImageModelV2; + } +} + +/** + * https://inference-docs.openrouter.ai/introduction + * Next release of OpenRouter provider should work instead of using OpenAIProvider + */ +export class OpenRouterLLM extends AiLLM { + constructor(displayName: string, model: string, maxInputTokens: number, calculateCosts: LlmCostFunction, defaultOptions?: GenerateTextOptions) { + super({ displayName, service: CEREBRAS_OPENROUTER_SERVICE, modelId: model, maxInputTokens, calculateCosts, oldIds: [], defaultOptions }); + } + + protected provider(): OpenRouterProvider { + return createOpenRouter({ + apiKey: this.apiKey(), + headers: { + 'HTTP-Referer': 'https://typedai.dev', // Optional. Site URL for rankings on openrouter.ai. + 'X-Title': 'TypedAI', // Optional. Site title for rankings on + }, + extraBody: { + provider: { + only: ['Cerebras'], + }, + }, + }); + } + + protected apiKey(): string | undefined { + return currentUser()?.llmConfig.openrouterKey || process.env.OPENROUTER_API_KEY; + } +} diff --git a/src/llm/services/llm.int.ts b/src/llm/services/llm.int.ts index b4f2a5f5b..26afde3a8 100644 --- a/src/llm/services/llm.int.ts +++ b/src/llm/services/llm.int.ts @@ -16,8 +16,8 @@ import type { LlmMessage } from '#shared/llm/llm.model'; import { setupConditionalLoggerOutput } from '#test/testUtils'; import { anthropicClaude4_1_Opus, anthropicClaude4_5_Sonnet } from './anthropic'; import { cerebrasQwen3_235b_Instruct } from './cerebras'; +import { openRouterQwen3_235b_Instruct, openRouterQwen3_235b_Thinking } from './cerebras-openrouter'; import { groqQwen3_32b } from './groq'; -import { openRouterQwen3_235b_Instruct, openRouterQwen3_235b_Thinking } from './openrouter'; const elephantBase64 = fs.readFileSync('test/llm/purple.jpg', 'base64'); const pdfBase64 = fs.readFileSync('test/llm/document.pdf', 'base64'); diff --git a/src/llm/services/openrouter.ts b/src/llm/services/openrouter.ts index ab0baf377..bb53b910e 100644 --- a/src/llm/services/openrouter.ts +++ b/src/llm/services/openrouter.ts @@ -9,19 +9,14 @@ export const OPENROUTER_SERVICE = 'openrouter'; export function openrouterLLMRegistry(): Record LLM> { return { - 'openrouter:qwen/qwen3-235b-a22b-thinking-2507': () => openRouterQwen3_235b_Thinking(), - 'openrouter:qwen/qwen/qwen3-235b-a22b-2507': () => openRouterQwen3_235b_Instruct(), + 'openrouter:morph/morph-v3-fast': () => openRouterMorph(), }; } // https://openrouter.ai/models -export function openRouterQwen3_235b_Thinking(): LLM { - return new OpenRouterLLM('Qwen3 235b Thinking (Cerebras)', 'qwen/qwen3-235b-a22b-thinking-2507', 131_000, costPerMilTokens(0.6, 1.2), {}); -} - -export function openRouterQwen3_235b_Instruct(): LLM { - return new OpenRouterLLM('Qwen3 235b Instruct (Cerebras)', 'qwen/qwen3-235b-a22b-2507', 131_000, costPerMilTokens(0.6, 1.2), {}); +export function openRouterMorph(): LLM { + return new OpenRouterLLM('Morph', 'morph/morph-v3-fast', 81_000, costPerMilTokens(0.8, 1.2), {}); } declare module '@openrouter/ai-sdk-provider' { @@ -66,11 +61,6 @@ export class OpenRouterLLM extends AiLLM { 'HTTP-Referer': 'https://typedai.dev', // Optional. Site URL for rankings on openrouter.ai. 'X-Title': 'TypedAI', // Optional. Site title for rankings on }, - extraBody: { - provider: { - only: ['Cerebras'], - }, - }, }); } diff --git a/src/routes/DOCS.md b/src/routes/DOCS.md index 29cfb2e82..2e773ebc8 100644 --- a/src/routes/DOCS.md +++ b/src/routes/DOCS.md @@ -1,5 +1,9 @@ # Fastify API routes +# Route registration + +All routes must be registered in src/routes/routeRegistry.ts + ## Sending responses Regular 2xx responses sending an object must use `reply.sendJSON(responseObject)` so there is type checking from the schema From 3fd4efaadcd2cc841e1829c474c6a31602c6f53c Mon Sep 17 00:00:00 2001 From: Daniel Campagnoli Date: Sat, 18 Oct 2025 03:16:28 +0800 Subject: [PATCH 2/2] Dynamic port allocation for server and frontend (for worktrees etc) --- frontend/package.json | 4 +- frontend/scripts/env-utils.js | 84 +++++++++++ frontend/scripts/env.js | 26 ++-- frontend/scripts/start-local.js | 128 ++++++++++++++++ package.json | 2 +- src/cli/startLocal.ts | 257 ++++++++++++++++++++++++++++++++ 6 files changed, 489 insertions(+), 12 deletions(-) create mode 100644 frontend/scripts/env-utils.js create mode 100644 frontend/scripts/start-local.js create mode 100644 src/cli/startLocal.ts diff --git a/frontend/package.json b/frontend/package.json index 4eb9b1c51..f738dfc99 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,11 +9,11 @@ "ng": "ng", "pnpm": " ng config -g cli.packageManager pnpm", "start": "ng serve --host 0.0.0.0", - "start:local": "pnpm run env:local && pnpm run start", + "start:local": "node scripts/start-local.js", "build": " pnpm run env:local && ng build", "build:stage": "pnpm run env:stage && ng build", "build:prod": " pnpm run env:prod && ng build --configuration production", - "env:local": "node --env-file=../variables/local.env scripts/env.js", + "env:local": "node scripts/env.js", "env:test": " node --env-file=../variables/test.env scripts/env.js", "env:stage": "node --env-file=../variables/stage.env scripts/env.js", "env:prod": " node --env-file=../variables/prod.env scripts/env.js", diff --git a/frontend/scripts/env-utils.js b/frontend/scripts/env-utils.js new file mode 100644 index 000000000..32fbed441 --- /dev/null +++ b/frontend/scripts/env-utils.js @@ -0,0 +1,84 @@ +const fs = require('fs'); +const path = require('path'); + +function resolveEnvFilePath() { + const cwd = process.cwd(); + const envFile = process.env.ENV_FILE; + if (envFile) { + const candidate = path.isAbsolute(envFile) ? envFile : path.resolve(cwd, envFile); + if (fs.existsSync(candidate)) return candidate; + } + const localEnv = path.resolve(cwd, '../variables/local.env'); + if (fs.existsSync(localEnv)) return localEnv; + if (process.env.TYPEDAI_HOME) { + const typedAiEnv = path.resolve(process.env.TYPEDAI_HOME, 'variables/local.env'); + if (fs.existsSync(typedAiEnv)) return typedAiEnv; + } + return null; +} + +function loadEnvFile(filePath) { + if (!filePath) return {}; + const contents = fs.readFileSync(filePath, 'utf8'); + const lines = contents.split(/\r?\n/); + const env = {}; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const equalIndex = line.indexOf('='); + if (equalIndex <= 0) continue; + const key = line.substring(0, equalIndex).trim().replace(/^export\s+/, ''); + if (!key) continue; + let value = line.substring(equalIndex + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + env[key] = value.replace(/\\n/g, '\n'); + } + return env; +} + +function hydrateProcessEnv() { + const envPath = resolveEnvFilePath(); + if (!envPath) { + console.warn('No environment file found; relying on existing environment variables.'); + return; + } + const vars = loadEnvFile(envPath); + for (const [key, value] of Object.entries(vars)) { + if (process.env[key] === undefined) process.env[key] = value; + } +} + +function determineBackendPort() { + if (process.env.BACKEND_PORT) return process.env.BACKEND_PORT; + if (process.env.PORT) return process.env.PORT; + try { + const runtimePath = path.resolve(process.cwd(), '../.typedai/runtime/backend.json'); + if (fs.existsSync(runtimePath)) { + const { backendPort } = JSON.parse(fs.readFileSync(runtimePath, 'utf8')); + if (backendPort) return String(backendPort); + } + } catch (error) { + console.warn('Unable to read backend runtime metadata.', error); + } + return null; +} + +function determineFrontendPort() { + if (process.env.FRONTEND_PORT) return process.env.FRONTEND_PORT; + if (process.env.UI_PORT) return process.env.UI_PORT; + if (process.env.UI_URL) { + const match = process.env.UI_URL.match(/:(\d+)/); + if (match) return match[1]; + } + return null; +} + +module.exports = { + resolveEnvFilePath, + loadEnvFile, + hydrateProcessEnv, + determineBackendPort, + determineFrontendPort, +}; diff --git a/frontend/scripts/env.js b/frontend/scripts/env.js index 1ba7f94e3..f4dd3aaa0 100644 --- a/frontend/scripts/env.js +++ b/frontend/scripts/env.js @@ -1,24 +1,32 @@ -const fs = require('fs'); const path = require('path'); +const fs = require('fs'); +const { + hydrateProcessEnv, + determineBackendPort, + determineFrontendPort, +} = require('./env-utils'); function generateEnvironmentFile() { + hydrateProcessEnv(); + + const backendPort = determineBackendPort(); + const resolvedApiBase = process.env.API_BASE_URL || (backendPort ? `http://localhost:${backendPort}/api/` : 'http://localhost:3000/api/'); + const frontPort = determineFrontendPort(); + const resolvedUiUrl = process.env.UI_URL || `http://localhost:${frontPort ?? '4200'}/`; + const envVars = { version: process.env.npm_package_version, - API_BASE_URL: process.env.API_BASE_URL, + API_BASE_URL: resolvedApiBase, GCLOUD_PROJECT: process.env.GCLOUD_PROJECT, DATABASE_NAME: process.env.DATABASE_NAME, DATABASE_TYPE: process.env.DATABASE_TYPE, AUTH: process.env.AUTH, MODULES: process.env.MODULES, }; - console.log(envVars) - for ([k,v] of Object.entries(envVars)) { - if (!v) console.info(`No value provided for ${k}`); - } - const environmentFile = `// This file is auto-generated by ${__filename} -export const env = ${JSON.stringify(envVars, null, 2)}; -`; + console.log('[frontend env] configuration', { API_BASE_URL: envVars.API_BASE_URL, UI_URL: resolvedUiUrl }); + + const environmentFile = `// This file is auto-generated by ${__filename}\nexport const env = ${JSON.stringify(envVars, null, 2)};\n`; const targetPath = path.join(__dirname, '../src/environments/.env.ts'); diff --git a/frontend/scripts/start-local.js b/frontend/scripts/start-local.js new file mode 100644 index 000000000..98d8c5a11 --- /dev/null +++ b/frontend/scripts/start-local.js @@ -0,0 +1,128 @@ +const { spawn } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const net = require('net'); +const { + hydrateProcessEnv, + determineBackendPort, + determineFrontendPort, + resolveEnvFilePath, + loadEnvFile, +} = require('./env-utils'); + +function findAvailablePort(preferred, attempts = 20) { + const ports = []; + if (preferred && Number(preferred) > 0) { + const base = Number(preferred); + for (let i = 0; i < attempts; i += 1) ports.push(base + i); + } + ports.push(0); + + return new Promise((resolve, reject) => { + const tryNext = () => { + if (ports.length === 0) { + reject(new Error('Unable to find available port for frontend dev server.')); + return; + } + const port = ports.shift(); + const server = net.createServer(); + server.once('error', () => { + server.close(); + tryNext(); + }); + server.listen({ port, host: '0.0.0.0', exclusive: true }, () => { + const address = server.address(); + server.close(() => { + if (address && typeof address === 'object') { + resolve(address.port); + } else { + resolve(port); + } + }); + }); + }; + tryNext(); + }); +} + +function ensurePortAvailable(port) { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', (error) => { + server.close(); + reject(new Error(`Port ${port} is unavailable: ${error.message}`)); + }); + server.listen({ port, host: '0.0.0.0', exclusive: true }, () => { + server.close(resolve); + }); + }); +} + +function applyEnvFile(filePath) { + if (!filePath || !fs.existsSync(filePath)) return; + const vars = loadEnvFile(filePath); + for (const [key, value] of Object.entries(vars)) { + if (process.env[key] === undefined) process.env[key] = value; + } +} + +function writeRuntimeMetadata(filePath, data) { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify({ ...data, updatedAt: new Date().toISOString() }, null, 2)); +} + +async function main() { + hydrateProcessEnv(); + applyEnvFile(resolveEnvFilePath()); + + const backendPort = determineBackendPort(); + const preferredFrontendPort = determineFrontendPort(); + const repoRoot = path.resolve(process.cwd(), '..'); + const typedAiHome = process.env.TYPEDAI_HOME ? path.resolve(process.env.TYPEDAI_HOME) : null; + const isDefaultRepo = typedAiHome ? repoRoot === typedAiHome : false; + + let frontendPort; + if (isDefaultRepo) { + frontendPort = 4200; + await ensurePortAvailable(frontendPort); + } else { + frontendPort = await findAvailablePort(preferredFrontendPort ? Number(preferredFrontendPort) : 4200); + } + + process.env.FRONTEND_PORT = String(frontendPort); + process.env.UI_URL = `http://localhost:${frontendPort}/`; + if (!process.env.API_BASE_URL && backendPort) { + process.env.API_BASE_URL = `http://localhost:${backendPort}/api/`; + } + + console.log('[frontend start] backend port:', backendPort || 'unknown'); + console.log('[frontend start] frontend port:', frontendPort); + + // Generate Angular runtime env file with the resolved variables. + require('./env.js'); + + writeRuntimeMetadata( + path.resolve(process.cwd(), '../.typedai/runtime/frontend.json'), + { + backendPort: backendPort ? Number(backendPort) : undefined, + frontendPort, + }, + ); + + const ngArgs = ['serve', '--host', '0.0.0.0', '--port', String(frontendPort)]; + const child = spawn('ng', ngArgs, { stdio: 'inherit', shell: process.platform === 'win32' }); + + child.on('exit', (code, signal) => { + if (signal) process.kill(process.pid, signal); + process.exit(typeof code === 'number' ? code : 0); + }); + + process.on('SIGINT', () => child.kill('SIGINT')); + process.on('SIGTERM', () => child.kill('SIGTERM')); +} + +main().catch((error) => { + console.error('[frontend start] failed to launch Angular dev server:', error); + process.exit(1); +}); diff --git a/package.json b/package.json index b8e11d1e8..e38be568a 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "initTiktokenizer": "node --env-file=variables/local.env -r esbuild-register src/initTiktokenizer.ts", "functionSchemas": "node --env-file=variables/local.env -r esbuild-register src/functionSchema/generateFunctionSchemas.ts", "start": " node -r ts-node/register --env-file=variables/.env src/index.ts", - "start:local": "node -r ts-node/register --env-file=variables/local.env --inspect=0.0.0.0:9229 src/index.ts", + "start:local": "node -r esbuild-register src/cli/startLocal.ts", "emulators": "gcloud emulators firestore start --host-port=127.0.0.1:8243", "test": " pnpm run test:unit && pnpm run test:db", "test:unit": " node --env-file=variables/test.env ./node_modules/mocha/bin/mocha -r esbuild-register -r \"./src/test/testSetup.ts\" \"src/**/*.test.[jt]s\" --exclude \"src/modules/{firestore,mongo,postgres}/*.test.ts\" --timeout 10000", diff --git a/src/cli/startLocal.ts b/src/cli/startLocal.ts new file mode 100644 index 000000000..9f9d7de2d --- /dev/null +++ b/src/cli/startLocal.ts @@ -0,0 +1,257 @@ +import '#fastify/trace-init/trace-init'; + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { open } from 'node:inspector'; +import { createRequire } from 'node:module'; +import { type Server as NetServer, createServer } from 'node:net'; +import path, { isAbsolute, resolve } from 'node:path'; +import { logger } from '#o11y/logger'; + +interface ResolveEnvFileOptions { + envFile?: string | null; + cwd?: string; + typedAiHome?: string | null; +} + +interface ApplyEnvOptions { + override?: boolean; +} + +type ParsedEnv = Record; + +/** + * Bootstraps the local backend server with dynamic ports and env-file fallback. + */ +async function main(): Promise { + let envFilePath: string | undefined; + try { + envFilePath = resolveEnvFilePath(); + applyEnvFile(envFilePath); + } catch (err) { + logger.warn(err, '[start-local] no environment file found; continuing with existing process.env'); + } + + process.env.NODE_ENV ??= 'development'; + + const repoRoot = path.resolve(process.cwd()); + const typedAiHome = process.env.TYPEDAI_HOME ? path.resolve(process.env.TYPEDAI_HOME) : null; + const isDefaultRepo = typedAiHome ? repoRoot === typedAiHome : false; + + const parsedPort = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : undefined; + let backendPort: number; + if (isDefaultRepo) { + backendPort = Number.isFinite(parsedPort) ? parsedPort! : 3000; + await ensurePortAvailable(backendPort); + } else { + backendPort = await findAvailablePort(Number.isFinite(parsedPort) ? parsedPort : 3000); + } + process.env.PORT = backendPort.toString(); + process.env.BACKEND_PORT = backendPort.toString(); + + const inspectorParsed = process.env.INSPECT_PORT ? Number.parseInt(process.env.INSPECT_PORT, 10) : undefined; + let inspectPort: number; + if (isDefaultRepo) { + inspectPort = Number.isFinite(inspectorParsed) ? inspectorParsed! : 9229; + await ensurePortAvailable(inspectPort); + } else { + inspectPort = await findAvailablePort(Number.isFinite(inspectorParsed) ? inspectorParsed : 9229); + } + process.env.INSPECT_PORT = inspectPort.toString(); + + const apiBaseUrl = `http://localhost:${backendPort}/api/`; + if (!process.env.API_BASE_URL || process.env.API_BASE_URL.includes('localhost:3000')) { + process.env.API_BASE_URL = apiBaseUrl; + } + + // Keep UI_URL loosely in sync for consumers that expect localhost links. + const defaultUiUrl = 'http://localhost:4200/'; + if (!process.env.UI_URL || process.env.UI_URL === defaultUiUrl) { + process.env.UI_URL = process.env.UI_URL ?? defaultUiUrl; + } + + if (envFilePath) { + logger.info(`[start-local] using env file ${envFilePath}`); + } + logger.info(`[start-local] backend listening on ${backendPort}`); + logger.info(`[start-local] inspector listening on ${inspectPort}`); + + try { + open(inspectPort, '0.0.0.0', false); + } catch (error) { + logger.warn(error, `[start-local] failed to open inspector on ${inspectPort}`); + } + + const runtimeMetadataPath = path.join(process.cwd(), '.typedai', 'runtime', 'backend.json'); + writeRuntimeMetadata(runtimeMetadataPath, { + envFilePath, + backendPort, + inspectPort, + }); + + const require = createRequire(__filename); + require('../index'); +} + +main().catch((error) => { + logger.fatal(error, '[start-local] failed to start backend'); + process.exitCode = 1; +}); + +function buildCandidatePath(value: string | null | undefined, cwd: string): string | null { + if (!value) return null; + if (isAbsolute(value)) return value; + return resolve(cwd, value); +} + +/** + * Resolves the path to the env file used for local development. + * Resolution order: explicit ENV_FILE → `variables/local.env` in the cwd → + * `$TYPEDAI_HOME/variables/local.env`. + */ +function resolveEnvFilePath(options: ResolveEnvFileOptions = {}): string { + const cwd = options.cwd ?? process.cwd(); + const envFileCandidate = buildCandidatePath(options.envFile ?? process.env.ENV_FILE, cwd); + const localEnvCandidate = resolve(cwd, 'variables', 'local.env'); + const typedAiHomeCandidate = options.typedAiHome ?? process.env.TYPEDAI_HOME; + const typedAiEnvCandidate = typedAiHomeCandidate ? resolve(typedAiHomeCandidate, 'variables', 'local.env') : null; + + const candidates = [envFileCandidate, localEnvCandidate, typedAiEnvCandidate]; + for (const candidate of candidates) { + if (!candidate) continue; + if (existsSync(candidate)) return candidate; + } + + throw new Error( + 'Could not locate environment file. Set ENV_FILE, create variables/local.env, or ensure TYPEDAI_HOME points to a repository that contains variables/local.env.', + ); +} + +/** + * Parses a dotenv style file into a plain key/value map. + * Lines without an equals sign or starting with `#` are ignored. + */ +function loadEnvFile(filePath: string): ParsedEnv { + if (!existsSync(filePath)) throw new Error(`Environment file not found at ${filePath}`); + const contents = readFileSync(filePath, 'utf8'); + const lines = contents.split(/\r?\n/); + const parsed: ParsedEnv = {}; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const equalIndex = line.indexOf('='); + if (equalIndex <= 0) continue; + + const key = line + .substring(0, equalIndex) + .trim() + .replace(/^export\s+/, ''); + if (!key) continue; + let value = line.substring(equalIndex + 1).trim(); + + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + value = value.replace(/\\n/g, '\n'); + parsed[key] = value; + } + + return parsed; +} + +/** + * Loads the file and assigns its values to `process.env`. + * Existing values are preserved unless `override` is set. + */ +function applyEnvFile(filePath: string, options: ApplyEnvOptions = {}): void { + const envVars = loadEnvFile(filePath); + const override = options.override ?? false; + + for (const [key, value] of Object.entries(envVars)) { + if (!override && process.env[key] !== undefined) continue; + process.env[key] = value; + } +} + +/** + * Writes JSON metadata describing the current runtime so other processes can + * discover the chosen configuration (e.g. ports). + */ +function writeRuntimeMetadata(targetPath: string, data: Record): void { + const dir = path.dirname(targetPath); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(targetPath, JSON.stringify({ ...data, updatedAt: new Date().toISOString() }, null, 2)); +} + +type ServerFactory = () => NetServer; + +let serverFactory: ServerFactory = () => createServer(); + +/** + * Overrides the net server factory used when probing ports. + * Primarily for tests where opening real sockets would fail in a sandbox. + */ +function setServerFactory(factory: ServerFactory | null): void { + serverFactory = factory ?? (() => createServer()); +} + +/** + * Attempts to find a free TCP port. Prefers the provided range before + * delegating to the OS (port 0). + */ +/** + * Attempts to find a free TCP port. Prefers the provided range before + * delegating to the OS (port 0). + */ +async function findAvailablePort(preferred?: number, attempts = 20): Promise { + const ports: number[] = []; + + if (preferred && preferred > 0) { + for (let i = 0; i < attempts; i++) { + ports.push(preferred + i); + } + } + + ports.push(0); + + for (const port of ports) { + try { + const resolved = await tryListen(port); + return resolved; + } catch {} + } + + throw new Error('Unable to find an available port'); +} + +/** Ensures a fixed port can be bound, throwing when it is already in use. */ +async function ensurePortAvailable(port: number): Promise { + try { + await tryListen(port); + } catch (error: any) { + const reason = error?.message ? `: ${error.message}` : ''; + throw new Error(`Port ${port} is unavailable${reason}`); + } +} + +async function tryListen(port: number): Promise { + return await new Promise((resolve, reject) => { + const server = serverFactory(); + + server.once('error', (error) => { + server.close(); + reject(error); + }); + + server.listen({ port, host: '0.0.0.0', exclusive: true }, () => { + const address = server.address(); + server.close(() => { + if (address && typeof address === 'object') { + resolve(address.port); + } else { + resolve(port); + } + }); + }); + }); +}