From 1561073c78a4c0e693a91cad2501e522320d1855 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:38:18 +0000 Subject: [PATCH 1/2] chore: release release --- .github/.release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ package.json | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json index cddbaefb9..c95106184 100644 --- a/.github/.release-please-manifest.json +++ b/.github/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.1.0" + ".": "1.2.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fa2ed919..e2dea4a35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.2.0](https://github.com/trycompai/crm/compare/v1.1.0...v1.2.0) (2026-08-07) + + +### Features + +* **api:** add microsoft sign-in and outlook mailbox sync ([#73](https://github.com/trycompai/crm/issues/73)) ([2a0062f](https://github.com/trycompai/crm/commit/2a0062fb76ffdaa5bbbb3848a5573b8b53cd0036)) + ## [1.1.0](https://github.com/trycompai/crm/compare/v1.0.0...v1.1.0) (2026-08-06) diff --git a/package.json b/package.json index 58f50e8b1..bd61fd43b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "crm", "private": true, "license": "MIT", - "version": "1.1.0", + "version": "1.2.0", "scripts": { "prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath .githooks || true", "build": "turbo run build", From 0e4dbbe975927d09ccd70a5238339915ac7cbef5 Mon Sep 17 00:00:00 2001 From: kuswardhanietidims-svg Date: Sat, 29 Aug 2026 09:15:41 +0000 Subject: [PATCH 2/2] feat: add OrcaRouter as a named model catalog provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When ORCAROUTER_API_KEY is set, the model picker on the settings page lists models from OrcaRouter's /v1/models endpoint instead of the Vercel AI Gateway. The same UX applies — choose a model, the agent uses it. The change mirrors the existing Vercel AI Gateway catalog service: - ModelCatalogService detects the source from the env var - Each source has its own parse function, cache key, and /v1/models URL - The front end reads the source field from the API response to label the card description and error messages - The new env var is declared in env.validation.ts, turbo.json pass- through, .env.example, and docs/environment.md --- .env.example | 8 ++ apps/api/src/config/env.validation.ts | 4 + .../api/src/settings/model-catalog.service.ts | 128 +++++++++++++++--- apps/api/src/settings/settings.contracts.ts | 1 + apps/api/src/settings/settings.service.ts | 6 +- apps/api/turbo.json | 2 + .../app/(app)/[slug]/settings/agent-model.tsx | 10 +- docs/environment.md | 1 + 8 files changed, 139 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index 12fac543c..c0d3b48d1 100644 --- a/.env.example +++ b/.env.example @@ -152,6 +152,14 @@ GOOGLE_CLIENT_SECRET="" # https://vercel.com/docs/ai-gateway # AI_GATEWAY_API_KEY="" +# OrcaRouter (https://www.orcarouter.ai) is an OpenAI-compatible AI gateway +# that routes to many models with adaptive routing, failover and zero-markup +# pricing. When ORCAROUTER_API_KEY is set, the model picker on the settings page +# lists OrcaRouter's catalog instead of the Vercel AI Gateway's, so a model can +# be chosen from there the same way. +# https://api.orcarouter.ai/v1/models +# ORCAROUTER_API_KEY="" + # ── Optional: operations ───────────────────────────────────────────────────── diff --git a/apps/api/src/config/env.validation.ts b/apps/api/src/config/env.validation.ts index 08cb676c2..151759e2d 100644 --- a/apps/api/src/config/env.validation.ts +++ b/apps/api/src/config/env.validation.ts @@ -109,6 +109,10 @@ export class EnvironmentVariables { @IsString() BLOB_READ_WRITE_TOKEN?: string; + @IsOptional() + @IsString() + ORCAROUTER_API_KEY?: string; + @IsOptional() @IsUrl( { require_tld: false, require_protocol: true }, diff --git a/apps/api/src/settings/model-catalog.service.ts b/apps/api/src/settings/model-catalog.service.ts index 13ebcbbff..ffa916a98 100644 --- a/apps/api/src/settings/model-catalog.service.ts +++ b/apps/api/src/settings/model-catalog.service.ts @@ -3,7 +3,11 @@ import { Inject, Injectable, Logger } from "@nestjs/common"; import type { Cache } from "cache-manager"; import { z } from "zod"; -const CATALOG_URL = "https://ai-gateway.vercel.sh/v1/models"; +const VERCEL_CATALOG_URL = "https://ai-gateway.vercel.sh/v1/models"; + +const ORCAROUTER_CATALOG_URL = "https://api.orcarouter.ai/v1/models"; + +const ORCAROUTER_API_KEY_ENV = "ORCAROUTER_API_KEY"; const CATALOG_TTL_MS = 30 * 60_000; @@ -11,6 +15,10 @@ const CATALOG_KEY = "settings:model-catalog"; const CATALOG_TIMEOUT_MS = 5_000; +const DEFAULT_ORCAROUTER_CONTEXT_WINDOW_TOKENS = 128_000; + +export type ModelCatalogSource = "vercel" | "orcarouter"; + export interface CatalogModel { id: string; name: string; @@ -45,6 +53,21 @@ const gatewayCatalog = z .object({ data: z.array(z.json()).catch([]) }) .catch({ data: [] }); +const orcaModel = z.object({ + id: z.string(), + name: z.string().catch(""), + owned_by: z.string().catch(""), + context_length: z.number().nullable().catch(null), + pricing: z + .object({ prompt: gatewayRate, completion: gatewayRate }) + .nullable() + .catch(null), +}); + +const orcaCatalog = z + .object({ data: z.array(z.json()).catch([]) }) + .catch({ data: [] }); + function usable(model: GatewayModel): boolean { return model.type === "language" && model.tags.includes("tool-use"); } @@ -62,20 +85,84 @@ function toCatalogModel(model: GatewayModel): CatalogModel { }; } +function parseVercelCatalog(body: unknown): CatalogModel[] { + const parsed = gatewayCatalog.safeParse(body); + const models: CatalogModel[] = []; + + if (parsed.success) { + for (const entry of parsed.data.data) { + const model = gatewayModel.safeParse(entry); + if (model.success && usable(model.data)) { + models.push(toCatalogModel(model.data)); + } + } + } + + models.sort( + (a, b) => + a.provider.localeCompare(b.provider) || a.name.localeCompare(b.name), + ); + + return models; +} + +function parseOrcaCatalog(body: unknown): CatalogModel[] { + const parsed = orcaCatalog.safeParse(body); + const models: CatalogModel[] = []; + + if (parsed.success) { + for (const entry of parsed.data.data) { + const model = orcaModel.safeParse(entry); + if (!model.success) continue; + + const input = model.data.pricing?.prompt ?? null; + const output = model.data.pricing?.completion ?? null; + + models.push({ + id: model.data.id, + name: model.data.name || model.data.id, + provider: + model.data.owned_by || (model.data.id.split("/")[0] ?? model.data.id), + contextWindowTokens: + model.data.context_length ?? DEFAULT_ORCAROUTER_CONTEXT_WINDOW_TOKENS, + pricing: input !== null && output !== null ? { input, output } : null, + }); + } + } + + models.sort( + (a, b) => + a.provider.localeCompare(b.provider) || a.name.localeCompare(b.name), + ); + + return models; +} + +export function catalogSource(): ModelCatalogSource { + return process.env[ORCAROUTER_API_KEY_ENV]?.trim() ? "orcarouter" : "vercel"; +} + @Injectable() export class ModelCatalogService { private readonly logger = new Logger(ModelCatalogService.name); constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {} + source(): ModelCatalogSource { + return catalogSource(); + } + async models(): Promise { - const cached = await this.cache.get(CATALOG_KEY); + const source = catalogSource(); + const cacheKey = `${CATALOG_KEY}:${source}`; + + const cached = await this.cache.get(cacheKey); if (cached) return cached; const models = await this.fetchCatalog(); if (!models) return null; - await this.cache.set(CATALOG_KEY, models, CATALOG_TTL_MS); + await this.cache.set(cacheKey, models, CATALOG_TTL_MS); return models; } @@ -85,36 +172,40 @@ export class ModelCatalogService { } private async fetchCatalog(): Promise { + const source = catalogSource(); + const url = + source === "orcarouter" ? ORCAROUTER_CATALOG_URL : VERCEL_CATALOG_URL; + try { - const response = await fetch(CATALOG_URL, { - headers: { accept: "application/json" }, + const headers: Record = { accept: "application/json" }; + + if (source === "orcarouter") { + const apiKey = process.env[ORCAROUTER_API_KEY_ENV]?.trim(); + if (apiKey) headers.authorization = `Bearer ${apiKey}`; + } + + const response = await fetch(url, { + headers, signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS), }); if (!response.ok) { this.logger.warn({ message: "Model catalog request failed", + source, status: response.status, }); return null; } - const body = gatewayCatalog.parse(await response.json()); - - const models = body.data.flatMap((entry) => { - const parsed = gatewayModel.safeParse(entry); - return parsed.success && usable(parsed.data) - ? [toCatalogModel(parsed.data)] - : []; - }); - - models.sort( - (a, b) => - a.provider.localeCompare(b.provider) || a.name.localeCompare(b.name), - ); + const models = + source === "orcarouter" + ? parseOrcaCatalog(await response.json()) + : parseVercelCatalog(await response.json()); this.logger.log({ message: "Model catalog loaded", + source, models: models.length, }); @@ -122,6 +213,7 @@ export class ModelCatalogService { } catch (error) { this.logger.warn({ message: "Model catalog unavailable", + source, reason: error instanceof Error ? error.message : String(error), }); return null; diff --git a/apps/api/src/settings/settings.contracts.ts b/apps/api/src/settings/settings.contracts.ts index aa8c91ffb..70565a4b3 100644 --- a/apps/api/src/settings/settings.contracts.ts +++ b/apps/api/src/settings/settings.contracts.ts @@ -27,6 +27,7 @@ export type AgentModelSettings = z.infer; export const modelCatalogOutput = z.object({ models: z.array(catalogModelOutput), available: z.boolean(), + source: z.enum(["vercel", "orcarouter"]), }); export type ModelCatalogResult = z.infer; diff --git a/apps/api/src/settings/settings.service.ts b/apps/api/src/settings/settings.service.ts index 1e8cfe857..a2edfa34b 100644 --- a/apps/api/src/settings/settings.service.ts +++ b/apps/api/src/settings/settings.service.ts @@ -82,7 +82,11 @@ export class SettingsService { async modelCatalog(): Promise { const models = await this.catalog.models(); - return { models: models ?? [], available: models !== null }; + return { + models: models ?? [], + available: models !== null, + source: this.catalog.source(), + }; } async researchKey(): Promise { diff --git a/apps/api/turbo.json b/apps/api/turbo.json index 04699fe51..d9a8918ee 100644 --- a/apps/api/turbo.json +++ b/apps/api/turbo.json @@ -31,6 +31,7 @@ "MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET", "MICROSOFT_TENANT_ID", + "ORCAROUTER_API_KEY", "PORT", "REDIS_URL" ] @@ -49,6 +50,7 @@ "MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET", "MICROSOFT_TENANT_ID", + "ORCAROUTER_API_KEY", "PORT", "REDIS_URL" ] diff --git a/apps/app/app/(app)/[slug]/settings/agent-model.tsx b/apps/app/app/(app)/[slug]/settings/agent-model.tsx index 6584a94c9..1a08ac294 100644 --- a/apps/app/app/(app)/[slug]/settings/agent-model.tsx +++ b/apps/app/app/(app)/[slug]/settings/agent-model.tsx @@ -37,6 +37,11 @@ type CatalogModel = { pricing: { input: number; output: number } | null; }; +const SOURCE_LABEL = { + vercel: "Vercel AI Gateway", + orcarouter: "OrcaRouter", +} as const; + const FOLLOW_DEFAULT = "__default__"; function perMillion(rate: number): string { @@ -90,6 +95,7 @@ export function AgentModel() { const { selectedId, effectiveId, defaultId, effective } = settings.data; const models = catalog.data?.models ?? []; const unavailable = catalog.data !== undefined && !catalog.data.available; + const source = catalog.data?.source ?? "vercel"; const defaultModel = models.find((model) => model.id === defaultId); const current = selectedId ?? FOLLOW_DEFAULT; @@ -111,7 +117,7 @@ export function AgentModel() { Research agent - The model the agent thinks with, routed through the Vercel AI Gateway. + The model the agent thinks with, via {SOURCE_LABEL[source]}. @@ -174,7 +180,7 @@ export function AgentModel() {

{unavailable - ? `Could not reach the AI Gateway to list models. The agent is still running ${effectiveId}.` + ? `Could not reach ${SOURCE_LABEL[source]} to list models. The agent is still running ${effectiveId}.` : effective ? `${effectiveId} · ${contextHint(effective.contextWindowTokens)}${ priceHint(effective) ? ` · ${priceHint(effective)}` : "" diff --git a/docs/environment.md b/docs/environment.md index 22417c60e..6ba53e1a6 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -115,6 +115,7 @@ single place that knows what is set. | `GITHUB_TOKEN` | Raises the GitHub rate limit from 60/hour | | `BLOB_READ_WRITE_TOKEN` | Mirrors logos and photos into Blob | | `AI_GATEWAY_API_KEY` | The model. Not needed on Vercel (OIDC) | +| `ORCAROUTER_API_KEY` | List models from OrcaRouter on the settings page instead of the Vercel AI Gateway | | `AGENT_BRIDGE_SECRET` | The rep-facing Agent panel — see `agent.md` | `BLOB_READ_WRITE_TOKEN` is also in `env.validation.ts` and `apps/api/turbo.json`