Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
52a57ed
Merge pull request #74 from trycompai/main
carhartlewis Aug 7, 2026
1561073
chore: release release
github-actions[bot] Aug 7, 2026
5074fc4
Merge pull request #75 from trycompai/release-please--branches--release
carhartlewis Aug 7, 2026
3c20d80
Merge pull request #77 from trycompai/main
carhartlewis Aug 7, 2026
808b835
Merge pull request #79 from trycompai/main
carhartlewis Aug 7, 2026
407280a
Merge pull request #81 from trycompai/main
carhartlewis Aug 7, 2026
c26a08d
Merge pull request #84 from trycompai/main
carhartlewis Aug 7, 2026
d585dc3
Merge pull request #90 from trycompai/main
carhartlewis Aug 8, 2026
d0299d9
Merge pull request #98 from trycompai/main
carhartlewis Aug 11, 2026
7d4a573
Merge pull request #107 from trycompai/main
carhartlewis Aug 11, 2026
56f4eeb
Merge pull request #116 from trycompai/main
github-actions[bot] Aug 11, 2026
57001e6
Merge pull request #119 from trycompai/main
github-actions[bot] Aug 11, 2026
ad1d702
Merge pull request #122 from trycompai/main
github-actions[bot] Aug 11, 2026
fc0c594
Merge pull request #127 from trycompai/main
github-actions[bot] Aug 11, 2026
4ffe150
Merge pull request #130 from trycompai/main
github-actions[bot] Aug 11, 2026
14cd220
Merge pull request #135 from trycompai/main
github-actions[bot] Aug 11, 2026
f2484fb
Merge pull request #141 from trycompai/main
github-actions[bot] Aug 12, 2026
bb63520
Merge pull request #161 from trycompai/main
github-actions[bot] Aug 18, 2026
517d859
Merge pull request #165 from trycompai/main
github-actions[bot] Aug 20, 2026
b842bd6
Merge pull request #168 from trycompai/main
github-actions[bot] Aug 20, 2026
77089e4
Merge pull request #172 from trycompai/main
github-actions[bot] Aug 20, 2026
6d4793d
Merge pull request #177 from trycompai/main
github-actions[bot] Aug 21, 2026
0e4dbbe
feat: add OrcaRouter as a named model catalog provider
kuswardhanietidims-svg Aug 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────

Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/config/env.validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
128 changes: 110 additions & 18 deletions apps/api/src/settings/model-catalog.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@ 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;

const CATALOG_KEY = "settings:model-catalog";

const CATALOG_TIMEOUT_MS = 5_000;

const DEFAULT_ORCAROUTER_CONTEXT_WINDOW_TOKENS = 128_000;

@cubic-dev-ai cubic-dev-ai Bot Aug 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: This tunable default is defined at the top of the service file, but AGENTS.md "Constants in One File per Area" requires tunable numbers to live in a named config module for their area, never at the top of the file that first needed them. Move the 128_000 default into the settings area config and import it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/settings/model-catalog.service.ts, line 18:

<comment>This tunable default is defined at the top of the service file, but AGENTS.md "Constants in One File per Area" requires tunable numbers to live in a named config module for their area, never at the top of the file that first needed them. Move the 128_000 default into the settings area config and import it.</comment>

<file context>
@@ -3,14 +3,22 @@ import { Inject, Injectable, Logger } from "@nestjs/common";
 
 const CATALOG_TIMEOUT_MS = 5_000;
 
+const DEFAULT_ORCAROUTER_CONTEXT_WINDOW_TOKENS = 128_000;
+
+export type ModelCatalogSource = "vercel" | "orcarouter";
</file context>
Fix with cubic


export type ModelCatalogSource = "vercel" | "orcarouter";

export interface CatalogModel {
id: string;
name: string;
Expand Down Expand Up @@ -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");
}
Expand All @@ -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({

@cubic-dev-ai cubic-dev-ai Bot Aug 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: When OrcaRouter is selected, this adds every /v1/models entry, including models with no supported endpoint and video-only models. Filter entries for an OpenAI text/tool-capable model before adding them, or users can select IDs the agent cannot execute.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/settings/model-catalog.service.ts, line 121:

<comment>When OrcaRouter is selected, this adds every `/v1/models` entry, including models with no supported endpoint and video-only models. Filter entries for an OpenAI text/tool-capable model before adding them, or users can select IDs the agent cannot execute.</comment>

<file context>
@@ -62,20 +85,84 @@ function toCatalogModel(model: GatewayModel): CatalogModel {
+			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,
</file context>
Fix with cubic

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<CatalogModel[] | null> {
const cached = await this.cache.get<CatalogModel[]>(CATALOG_KEY);
const source = catalogSource();
const cacheKey = `${CATALOG_KEY}:${source}`;

const cached = await this.cache.get<CatalogModel[]>(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;
}

Expand All @@ -85,43 +172,48 @@ export class ModelCatalogService {
}

private async fetchCatalog(): Promise<CatalogModel[] | null> {
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<string, string> = { 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,
});

return models;
} catch (error) {
this.logger.warn({
message: "Model catalog unavailable",
source,
reason: error instanceof Error ? error.message : String(error),
});
return null;
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/settings/settings.contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type AgentModelSettings = z.infer<typeof agentModelOutput>;
export const modelCatalogOutput = z.object({
models: z.array(catalogModelOutput),
available: z.boolean(),
source: z.enum(["vercel", "orcarouter"]),
});

export type ModelCatalogResult = z.infer<typeof modelCatalogOutput>;
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/settings/settings.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,11 @@ export class SettingsService {

async modelCatalog(): Promise<ModelCatalogResult> {
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<ResearchKeySettings> {
Expand Down
2 changes: 2 additions & 0 deletions apps/api/turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"MICROSOFT_CLIENT_ID",
"MICROSOFT_CLIENT_SECRET",
"MICROSOFT_TENANT_ID",
"ORCAROUTER_API_KEY",
"PORT",
"REDIS_URL"
]
Expand All @@ -49,6 +50,7 @@
"MICROSOFT_CLIENT_ID",
"MICROSOFT_CLIENT_SECRET",
"MICROSOFT_TENANT_ID",
"ORCAROUTER_API_KEY",
"PORT",
"REDIS_URL"
]
Expand Down
10 changes: 8 additions & 2 deletions apps/app/app/(app)/[slug]/settings/agent-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -111,7 +117,7 @@ export function AgentModel() {
<CardHeader>
<CardTitle>Research agent</CardTitle>
<CardDescription>
The model the agent thinks with, routed through the Vercel AI Gateway.
The model the agent thinks with, via {SOURCE_LABEL[source]}.
</CardDescription>
</CardHeader>

Expand Down Expand Up @@ -174,7 +180,7 @@ export function AgentModel() {

<p className="text-muted-foreground text-xs">
{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)}` : ""
Expand Down
1 change: 1 addition & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading