From a3a911e2f405cd2c213bec7905e69e4898e1d1dc Mon Sep 17 00:00:00 2001 From: Felipe Almeman <4226997+zhiru@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:26:18 -0300 Subject: [PATCH] feat(api): VS Code Copilot Ollama-compatible BYOK endpoint (#3316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.13 — VS Code Copilot Ollama-compatible BYOK endpoint (reconciled with #3306/#3309 auth hardening) --- CHANGELOG.md | 4 +- docs/frameworks/MCP-SERVER.md | 2 +- .../dashboard/endpoint/ApiEndpointsTab.tsx | 53 +- .../dashboard/endpoint/EndpointPageClient.tsx | 3 + .../endpoint/VscodeTokenAliasCard.tsx | 213 +++ .../__tests__/ApiEndpointsTab.test.tsx | 111 +- .../__tests__/EndpointPageClient.test.tsx | 38 +- .../api/v1/vscode/[token]/api/chat/route.ts | 1 + .../api/v1/vscode/[token]/api/show/route.ts | 329 +++++ .../api/v1/vscode/[token]/api/tags/route.ts | 257 ++++ .../v1/vscode/[token]/api/version/route.ts | 20 + .../vscode/[token]/chat/completions/route.ts | 10 + src/app/api/v1/vscode/[token]/combos/route.ts | 41 + .../v1/vscode/[token]/familyFirstModelIds.ts | 85 ++ .../v1/vscode/[token]/modelPresentation.ts | 178 +++ src/app/api/v1/vscode/[token]/models/route.ts | 424 ++++++ .../v1/vscode/[token]/reasoningMetadata.ts | 175 +++ .../api/v1/vscode/[token]/responses/route.ts | 10 + src/app/api/v1/vscode/[token]/route.ts | 20 + .../v1/vscode/[token]/serviceTierVariants.ts | 190 +++ .../api/v1/vscode/[token]/tokenizedRequest.ts | 50 + .../[token]/v1/chat/completions/route.ts | 10 + .../api/v1/vscode/[token]/v1/models/route.ts | 1 + .../combos/[token]/[[...slug]]/route.ts | 421 ++++++ .../v1/vscode/raw/[token]/api/chat/route.ts | 5 + .../v1/vscode/raw/[token]/api/show/route.ts | 327 +++++ .../v1/vscode/raw/[token]/api/tags/route.ts | 213 +++ .../vscode/raw/[token]/api/version/route.ts | 20 + .../raw/[token]/chat/completions/route.ts | 10 + .../api/v1/vscode/raw/[token]/combos/route.ts | 41 + .../vscode/raw/[token]/familyFirstModelIds.ts | 85 ++ .../vscode/raw/[token]/modelPresentation.ts | 178 +++ .../api/v1/vscode/raw/[token]/models/route.ts | 43 + .../vscode/raw/[token]/reasoningMetadata.ts | 175 +++ .../v1/vscode/raw/[token]/responses/route.ts | 10 + src/app/api/v1/vscode/raw/[token]/route.ts | 20 + .../vscode/raw/[token]/serviceTierVariants.ts | 190 +++ .../v1/vscode/raw/[token]/tokenizedRequest.ts | 50 + .../raw/[token]/v1/chat/completions/route.ts | 10 + .../v1/vscode/raw/[token]/v1/models/route.ts | 1 + tests/unit/vscode-token-routes.test.ts | 1207 +++++++++++++++++ 41 files changed, 5188 insertions(+), 43 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/endpoint/VscodeTokenAliasCard.tsx create mode 100644 src/app/api/v1/vscode/[token]/api/chat/route.ts create mode 100644 src/app/api/v1/vscode/[token]/api/show/route.ts create mode 100644 src/app/api/v1/vscode/[token]/api/tags/route.ts create mode 100644 src/app/api/v1/vscode/[token]/api/version/route.ts create mode 100644 src/app/api/v1/vscode/[token]/chat/completions/route.ts create mode 100644 src/app/api/v1/vscode/[token]/combos/route.ts create mode 100644 src/app/api/v1/vscode/[token]/familyFirstModelIds.ts create mode 100644 src/app/api/v1/vscode/[token]/modelPresentation.ts create mode 100644 src/app/api/v1/vscode/[token]/models/route.ts create mode 100644 src/app/api/v1/vscode/[token]/reasoningMetadata.ts create mode 100644 src/app/api/v1/vscode/[token]/responses/route.ts create mode 100644 src/app/api/v1/vscode/[token]/route.ts create mode 100644 src/app/api/v1/vscode/[token]/serviceTierVariants.ts create mode 100644 src/app/api/v1/vscode/[token]/tokenizedRequest.ts create mode 100644 src/app/api/v1/vscode/[token]/v1/chat/completions/route.ts create mode 100644 src/app/api/v1/vscode/[token]/v1/models/route.ts create mode 100644 src/app/api/v1/vscode/combos/[token]/[[...slug]]/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/api/chat/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/api/show/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/api/tags/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/api/version/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/chat/completions/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/combos/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/familyFirstModelIds.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/modelPresentation.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/models/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/reasoningMetadata.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/responses/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/serviceTierVariants.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/tokenizedRequest.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/v1/chat/completions/route.ts create mode 100644 src/app/api/v1/vscode/raw/[token]/v1/models/route.ts create mode 100644 tests/unit/vscode-token-routes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6512529ebf..67d94de45f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ - **fix(codex):** strip client-only params (`prompt_cache_retention`, `safety_identifier`, `user`) on the native `codex/` `/v1/responses` passthrough — Codex upstream rejects them with `400 Unsupported parameter`, which broke Factory Droid and any client injecting those fields. The chat-completions path already stripped them; the responses→responses passthrough now does too. (#3317 — thanks @tycronk20) - **fix(theoldllm):** stop the `[502]: Body is unusable: Body has already been read` error on the cached-token path — the executor read the same upstream `Response` body with `.text()` twice; it now reads it once and only re-reads after a token-rejection refetch. (#3296 — thanks @onizukashonan14-png) - **fix(dashboard):** keep no-auth providers (opencode, duckduckgo-web, theoldllm, veoaifree-web) visible under the "Show configured only" filter — they never create a connection row (`stats.total === 0`) but are always usable and already appear in `/v1/models`, so the filter now treats `displayAuthType === "no-auth"` as configured. (#3290 — thanks @uniQta) +- **fix(dashboard):** refresh the connection list after a Codex/Claude/Gemini auth import — the import modals called `fetchData()` (which only reloads provider metadata), so a freshly-imported connection stayed invisible until a manual reload; they now call `fetchConnections()`. ([#3320](https://github.com/diegosouzapw/OmniRoute/pull/3320) — thanks @zhiru) - **fix(cli):** `omniroute update` no longer always fails on a global install — `getCurrentVersion()` and `createBackup()` now resolve `package.json`/`bin` relative to the script (`import.meta.url`) instead of `process.cwd()` (the user's working dir on a global npm/brew install → *"Could not determine current version"*), and the backup copies the `cli` directory with `cpSync({recursive:true})` instead of `copyFileSync`, which threw a swallowed `EISDIR` → *"Failed to create backup. Aborting"*. (#3295 — thanks @uniQta) - **fix(sse):** harden the passthrough stream against empty upstream responses — emit a synthetic retry chunk on an empty `choices: []` (fixes a Copilot Chat crash) and log empty post-`tool_calls` completions; also registers **MiniMax M3** (1M context) across 8 provider tiers. ([#3297](https://github.com/diegosouzapw/OmniRoute/pull/3297), #3110 — thanks @wilsonicdev) - **fix(opencode-provider):** extract `contextLength` from the live `/v1/models` catalog (live > `modelContextLengths` > static map) so passthrough models outside the legacy 8-model map no longer silently truncate to OpenCode's 128K default. ([#3298](https://github.com/diegosouzapw/OmniRoute/pull/3298) — thanks @herjarsa / @diegosouzapw) @@ -62,7 +63,8 @@ Thanks to everyone whose work landed in v3.8.13: | Contributor | PRs / Issues | | --- | --- | -| [@zhiru](https://github.com/zhiru) | #3300, #3306, #3307 / #3310, #3309, #3301, #3311 | +| [@zhiru](https://github.com/zhiru) | #3300, #3306, #3307 / #3310, #3309, #3301, #3311, #3320 | +| [@tycronk20](https://github.com/tycronk20) | #3317, #3318 | | [@oyi77](https://github.com/oyi77) | #3292 (closes #3070) | | [@onizukashonan14-png](https://github.com/onizukashonan14-png) | #3296 | | [@uniQta](https://github.com/uniQta) | #3290, #3295 | diff --git a/docs/frameworks/MCP-SERVER.md b/docs/frameworks/MCP-SERVER.md index 3e5790fb91..4c1bb66a97 100644 --- a/docs/frameworks/MCP-SERVER.md +++ b/docs/frameworks/MCP-SERVER.md @@ -10,7 +10,7 @@ lastUpdated: 2026-05-30 > > Source of truth: `open-sse/mcp-server/schemas/tools.ts` (30 tools) + `open-sse/mcp-server/tools/memoryTools.ts` (3 tools) + `open-sse/mcp-server/tools/skillTools.ts` (4 tools) + `open-sse/mcp-server/tools/notionTools.ts` (6 tools). Tool registration and scope wiring lives in `open-sse/mcp-server/server.ts`. -![MCP tool inventory (43 tools by category)](../diagrams/exported/mcp-tools-43.svg) +![MCP tool inventory (43 tools by category)](../diagrams/exported/mcp-tools-37.svg) > Source: [diagrams/mcp-tools-43.mmd](../diagrams/mcp-tools-43.mmd) (regenerate via `npm run docs:render-diagrams`). diff --git a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx index 9ffa88f36c..2489e7ccfd 100644 --- a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from "react"; import { useTranslations } from "next-intl"; import { Card } from "@/shared/components"; import { useDisplayBaseUrl } from "@/shared/hooks"; +import VscodeTokenAliasCard from "./VscodeTokenAliasCard"; import { matchesSearch } from "@/shared/utils/turkishText"; /* ─── Types ──────────────────────────────────────────── */ @@ -271,31 +272,35 @@ export default function ApiEndpointsTab() { {/* ═══ API CATALOG ═══ */} {!catalog && ( - -
-
- error -
-
-

- {t("apiEndpointsCatalogUnavailable")} -

-

- {catalogError || "The OpenAPI specification could not be loaded."} -

- +
+
+ error +
+
+

+ {t("apiEndpointsCatalogUnavailable")} +

+

+ {catalogError || "The OpenAPI specification could not be loaded."} +

+
- open_in_new - Open JSON response - + > + open_in_new + Open JSON response + +
-
- + + + + )} {catalog && ( @@ -385,6 +390,8 @@ export default function ApiEndpointsTab() {
+ + {/* Endpoint groups */} {Object.entries(groupedEndpoints).map(([tag, endpoints]) => ( diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index e3461e4a1e..4feb02b230 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -12,6 +12,7 @@ import A2ADashboardPage from "./components/A2ADashboard"; import McpDashboardPage from "./components/MCPDashboard"; import TokenSaverCard from "./components/TokenSaverCard"; import NotionSourceCard from "./components/NotionSourceCard"; +import VscodeTokenAliasCard from "./VscodeTokenAliasCard"; const BUILD_TIME_CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL || null; const CLOUD_ACTION_TIMEOUT_MS = 15000; @@ -2023,6 +2024,8 @@ export default function APIPageClient({ machineId }: Readonly + + diff --git a/src/app/(dashboard)/dashboard/endpoint/VscodeTokenAliasCard.tsx b/src/app/(dashboard)/dashboard/endpoint/VscodeTokenAliasCard.tsx new file mode 100644 index 0000000000..b693c0b4a2 --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/VscodeTokenAliasCard.tsx @@ -0,0 +1,213 @@ +"use client"; + +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { useEffect, useMemo, useState } from "react"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; +import { useDisplayBaseUrl } from "@/shared/hooks"; +import { Card } from "@/shared/components"; + +type EndpointApiKeySummary = { + id: string; + key: string; + rawKey?: string; + isActive?: boolean; +}; + +export default function VscodeTokenAliasCard({ + className = "", + variant = "highlight", +}: Readonly<{ className?: string; variant?: "highlight" | "catalog" }>) { + const t = useTranslations("endpoint"); + const [cliApiKeys, setCliApiKeys] = useState([]); + const [keysLoading, setKeysLoading] = useState(true); + const [keysError, setKeysError] = useState(false); + const { copied, copy } = useCopyToClipboard(); + const displayBaseUrl = useDisplayBaseUrl(); + + useEffect(() => { + let cancelled = false; + + const loadCliApiKeys = async () => { + setKeysLoading(true); + setKeysError(false); + + try { + const res = await fetch("/api/cli-tools/keys"); + if (!res.ok) { + throw new Error("Failed to load CLI keys"); + } + + const data = await res.json(); + if (!cancelled) { + setCliApiKeys(data.keys || []); + } + } catch { + if (!cancelled) { + setCliApiKeys([]); + setKeysError(true); + } + } finally { + if (!cancelled) { + setKeysLoading(false); + } + } + }; + + void loadCliApiKeys(); + + return () => { + cancelled = true; + }; + }, []); + + const preferredCliApiKey = useMemo(() => { + if (cliApiKeys.length === 0) { + return null; + } + + const storedCopilotKeyId = + typeof window !== "undefined" ? window.localStorage.getItem("omniroute-cli-key-copilot") : null; + + return ( + (storedCopilotKeyId ? cliApiKeys.find((key) => key.id === storedCopilotKeyId) : null) ?? + cliApiKeys.find((key) => key.isActive !== false) ?? + cliApiKeys[0] ?? + null + ); + }, [cliApiKeys]); + + const tokenSegment = preferredCliApiKey?.rawKey || "{token}"; + const hasRealToken = Boolean(preferredCliApiKey?.rawKey); + const translationToken = { token: "{token}" }; + const baseUrl = `${displayBaseUrl}/api/v1/vscode/${tokenSegment}`; + const vscodeTokenizedUrls = [ + { + label: t("vscodeAliasBaseLabel"), + url: `${baseUrl}/`, + key: "vscode_token_base", + }, + { + label: t("vscodeAliasModelsLabel"), + url: `${baseUrl}/models`, + key: "vscode_token_models", + }, + { + label: t("vscodeAliasChatLabel"), + url: `${baseUrl}/chat/completions`, + key: "vscode_token_chat", + }, + ]; + + const description = hasRealToken + ? t("vscodeAliasDescriptionReady", translationToken) + : keysError + ? t("vscodeAliasDescriptionError") + : keysLoading + ? t("vscodeAliasDescriptionLoading") + : t("vscodeAliasDescriptionPlaceholder", translationToken); + + if (variant === "catalog") { + return ( + +
+ key +

+ {t("vscodeAliasTitle")} +

+
+ + {t("vscodeAliasManage")} + +
+ +
+

{description}

+ +
+ {vscodeTokenizedUrls.map(({ label, url, key }) => ( + void copy(text, copyKey)} + copied={copied} + variant="catalog" + /> + ))} +
+
+ + ); + } + + return ( +
+
+
+

+ {t("vscodeAliasTitle")} +

+

{description}

+
+ + {t("vscodeAliasManage")} + +
+ +
+ {vscodeTokenizedUrls.map(({ label, url, key }) => ( + void copy(text, copyKey)} + copied={copied} + variant="highlight" + /> + ))} +
+
+ ); +} + +function CopyableEndpointRow({ + label, + url, + copyKey, + copy, + copied, + variant = "highlight", +}: Readonly<{ + label: string; + url: string; + copyKey: string; + copy: (text: string, key?: string) => void; + copied?: string | null; + variant?: "highlight" | "catalog"; +}>) { + const rowClassName = + variant === "catalog" + ? "flex items-center gap-2 min-w-0 rounded-lg border border-black/10 dark:border-white/10 bg-black/[0.02] dark:bg-white/[0.02] px-3 py-2.5" + : "flex items-center gap-2 min-w-0 rounded-md border border-sky-500/15 bg-background/60 px-2.5 py-2"; + + return ( +
+ {label} + {url} + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx b/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx index 8b7aa4e33f..58b46fdd08 100644 --- a/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/__tests__/ApiEndpointsTab.test.tsx @@ -3,6 +3,58 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import ApiEndpointsTab from "../ApiEndpointsTab"; +import { DEFAULT_DISPLAY_BASE_URL } from "@/shared/hooks/useDisplayBaseUrl"; + +vi.mock("next/link", () => ({ + default: ({ href, children, ...props }: React.AnchorHTMLAttributes) => ( + + {children} + + ), +})); + +vi.mock("next-intl", () => ({ + useTranslations: (namespace?: string) => { + const messages: Record = { + "endpoint.apiEndpointsCatalogUnavailable": "API catalog unavailable", + "endpoint.apiEndpointsSearchPlaceholder": "Search endpoints", + "endpoint.badgeLoopbackTooltip": "Loopback only", + "endpoint.badgeAlwaysProtectedTooltip": "Always protected", + "endpoint.badgeInternalTooltip": "Internal endpoint", + "endpoint.tierAll": "All", + "endpoint.tierAuth": "Auth", + "endpoint.tierLoopback": "Loopback", + "endpoint.tierAlwaysProtected": "Protected", + "endpoint.tierPublic": "Public", + "endpoint.showInternal": "Show internal", + "endpoint.hideInternal": "Hide internal", + "endpoint.vscodeAliasTitle": "VS Code Token Alias", + "endpoint.vscodeAliasDescriptionReady": "Ready-to-paste compatibility URLs using the /api/v1/vscode/{token}/... endpoint.", + "endpoint.vscodeAliasDescriptionError": "Showing placeholder URLs because CLI keys could not be loaded in this session.", + "endpoint.vscodeAliasDescriptionLoading": "Loading CLI keys. Placeholder URLs are shown until a key is available.", + "endpoint.vscodeAliasDescriptionPlaceholder": "Showing placeholder URLs. Create or activate an API key in CLI Tools to replace {token}.", + "endpoint.vscodeAliasManage": "CLI Tools", + "endpoint.vscodeAliasBaseLabel": "VS Code base", + "endpoint.vscodeAliasModelsLabel": "VS Code models", + "endpoint.vscodeAliasChatLabel": "VS Code chat", + "endpoint.tryIt": "Try it", + "endpoint.parameters": "Parameters", + "endpoint.responses": "Responses", + "endpoint.requestBody": "Request body", + "endpoint.description": "Description", + "endpoint.noDescription": "No description", + "endpoint.security": "Security", + "endpoint.authRequired": "Auth required", + "endpoint.noAuth": "No auth", + "endpoint.execute": "Execute", + "endpoint.executing": "Executing", + "endpoint.close": "Close", + "endpoint.openJsonResponse": "Open JSON response", + }; + + return (key: string) => messages[`${namespace}.${key}`] || key; + }, +})); function jsonResponse(data: unknown, status = 200) { return { @@ -66,18 +118,32 @@ describe("ApiEndpointsTab", () => { }); it("shows an API catalog error state instead of a blank page", async () => { - fetchMock.mockResolvedValue(jsonResponse({ error: "openapi.yaml not found" }, 404)); + fetchMock.mockImplementation(async (input) => { + if (input === "/api/cli-tools/keys") { + return jsonResponse({ keys: [] }); + } + + return jsonResponse({ error: "openapi.yaml not found" }, 404); + }); renderApiEndpointsTab(); + await waitForText("VS Code Token Alias"); await waitForText("API catalog unavailable"); expect(document.body.textContent).toContain("openapi.yaml not found"); + expect(document.body.textContent).toContain("/api/v1/vscode/{token}/models"); expect(document.body.textContent).toContain("Open JSON response"); }); it("renders catalog content when the OpenAPI catalog loads", async () => { - fetchMock.mockResolvedValue( - jsonResponse({ + fetchMock.mockImplementation(async (input) => { + if (input === "/api/cli-tools/keys") { + return jsonResponse({ + keys: [{ id: "copilot", key: "sk-***", rawKey: "sk-live-123", isActive: true }], + }); + } + + return jsonResponse({ info: { title: "OmniRoute API", version: "3.7.6" }, servers: [], tags: [{ name: "Chat" }], @@ -95,19 +161,25 @@ describe("ApiEndpointsTab", () => { }, ], schemas: [], - }) - ); + }); + }); renderApiEndpointsTab(); + await waitForText("VS Code Token Alias"); await waitForText("OmniRoute API"); expect(document.body.textContent).toContain("1 endpoints across 1 categories"); + expect(document.body.textContent).toContain("/api/v1/vscode/sk-live-123/models"); expect(document.body.textContent).toContain("/api/v1/chat/completions"); }); it("renders curl example using window.location.origin when NEXT_PUBLIC_BASE_URL is unset", async () => { - fetchMock.mockResolvedValue( - jsonResponse({ + fetchMock.mockImplementation(async (input) => { + if (input === "/api/cli-tools/keys") { + return jsonResponse({ keys: [] }); + } + + return jsonResponse({ info: { title: "OmniRoute API", version: "3.7.6" }, servers: [], tags: [{ name: "Chat" }], @@ -125,27 +197,32 @@ describe("ApiEndpointsTab", () => { }, ], schemas: [], - }) - ); + }); + }); renderApiEndpointsTab(); await waitForText("OmniRoute API"); // Expand the endpoint to reveal the curl example - const endpointRow = document.body.querySelector("code.font-mono.flex-1"); + const endpointRow = Array.from(document.body.querySelectorAll("code")).find((node) => + node.textContent?.includes("/api/v1/chat/completions") + ); if (endpointRow?.parentElement) { await act(async () => { endpointRow.parentElement!.click(); }); } - // After mount the hook swaps DEFAULT_DISPLAY_BASE_URL for window.location.origin. - // In jsdom the default origin is "http://localhost". - const expectedOrigin = window.location.origin; // "http://localhost" in jsdom - await waitForText(`curl -X POST ${expectedOrigin}/v1/chat/completions`); - expect(document.body.textContent).toContain( - `curl -X POST ${expectedOrigin}/v1/chat/completions` - ); + await waitForText("curl -X POST"); + + const renderedText = document.body.textContent || ""; + const expectedOrigins = [window.location.origin, DEFAULT_DISPLAY_BASE_URL]; + + expect( + expectedOrigins.some((origin) => + renderedText.includes(`curl -X POST ${origin}/v1/chat/completions`) + ) + ).toBe(true); }); }); diff --git a/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx b/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx index 3c92edfe9a..acb0ccd5f5 100644 --- a/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/__tests__/EndpointPageClient.test.tsx @@ -118,6 +118,15 @@ vi.mock("next-intl", () => ({ "endpoint.categoryCore": "Core APIs", "endpoint.categoryMedia": "Media APIs", "endpoint.categoryUtility": "Utility APIs", + "endpoint.vscodeAliasTitle": "VS Code Token Alias", + "endpoint.vscodeAliasDescriptionReady": "Ready-to-paste compatibility URLs using the /api/v1/vscode/{token}/... endpoint.", + "endpoint.vscodeAliasDescriptionError": "Showing placeholder URLs because CLI keys could not be loaded in this session.", + "endpoint.vscodeAliasDescriptionLoading": "Loading CLI keys. Placeholder URLs are shown until a key is available.", + "endpoint.vscodeAliasDescriptionPlaceholder": "Showing placeholder URLs. Create or activate an API key in CLI Tools to replace {token}.", + "endpoint.vscodeAliasManage": "CLI Tools", + "endpoint.vscodeAliasBaseLabel": "VS Code base", + "endpoint.vscodeAliasModelsLabel": "VS Code models", + "endpoint.vscodeAliasChatLabel": "VS Code chat", "endpoint.machineId": "Machine {id}", "endpoint.usingLocalServer": "Using local server", "common.copy": "Copy", @@ -187,6 +196,30 @@ describe("EndpointPageClient", () => { if (path === "/api/search/providers") { return Promise.resolve(jsonResponse({ providers: [] })); } + if (path === "/api/cli-tools/keys") { + return Promise.resolve( + jsonResponse({ + keys: [ + { + id: "key-1", + key: "sk-test-1234", + rawKey: "sk-test-1234", + isActive: true, + }, + ], + }) + ); + } + if (path === "/api/settings/compression") { + return Promise.resolve( + jsonResponse({ + enabled: true, + cavemanConfig: { enabled: true, intensity: "full" }, + cavemanOutputMode: { enabled: false, intensity: "full" }, + rtkConfig: { enabled: true, intensity: "standard" }, + }) + ); + } throw new Error(`Unexpected request: ${path}`); }); @@ -217,7 +250,10 @@ describe("EndpointPageClient", () => { }) ); - await waitForText("2 models across 4 endpoints"); + await waitForText("2 models across"); + await waitForText("/api/v1/vscode/sk-test-1234/models"); + expect(document.body.textContent).toContain("VS Code Token Alias"); + expect(document.body.textContent).toContain("/api/v1/vscode/sk-test-1234/models"); }); it("does not start background endpoint requests after unmounting during settings load", async () => { diff --git a/src/app/api/v1/vscode/[token]/api/chat/route.ts b/src/app/api/v1/vscode/[token]/api/chat/route.ts new file mode 100644 index 0000000000..4295e62c16 --- /dev/null +++ b/src/app/api/v1/vscode/[token]/api/chat/route.ts @@ -0,0 +1 @@ +export { POST, OPTIONS } from "@/app/api/v1/api/chat/route"; \ No newline at end of file diff --git a/src/app/api/v1/vscode/[token]/api/show/route.ts b/src/app/api/v1/vscode/[token]/api/show/route.ts new file mode 100644 index 0000000000..f48a500ea8 --- /dev/null +++ b/src/app/api/v1/vscode/[token]/api/show/route.ts @@ -0,0 +1,329 @@ +import { getUnifiedModelsResponse } from "@/app/api/v1/models/catalog"; +import { getCanonicalModelMetadata } from "@/lib/modelMetadataRegistry"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { + buildReasoningConfigSchema, + buildSupportedReasoningEfforts, + getDefaultReasoningEffort, + getReasoningEffortValues, + inferSelectedReasoningEffort, + type VscodeCatalogModel, +} from "@/app/api/v1/vscode/[token]/reasoningMetadata"; +import { getVscodeModelDisplayName } from "@/app/api/v1/vscode/[token]/modelPresentation"; +import { + expandVscodeServiceTierModels, + parseVscodeServiceTierVariantModelId, +} from "@/app/api/v1/vscode/[token]/serviceTierVariants"; +import { getFamilyFirstModelCandidates, getFamilyFirstPublishedModelId } from "@/app/api/v1/vscode/[token]/familyFirstModelIds"; +import { withPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest"; + +type OpenAiCatalogModel = { + id?: string; + name?: string; + root?: string; + parent?: string | null; + owned_by?: string; + type?: string; + api_format?: string; + context_length?: number; + max_output_tokens?: number; + capabilities?: Record; + input_modalities?: string[]; + output_modalities?: string[]; + supported_endpoints?: string[]; +}; + +function isUsableChatModel(model: OpenAiCatalogModel) { + if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { + return false; + } + if (typeof model.parent === "string" && model.parent.length > 0) return false; + if (typeof model.type === "string" && model.type !== "chat") return false; + + const apiFormat = typeof model.api_format === "string" ? model.api_format : "chat-completions"; + if (apiFormat !== "chat-completions") return false; + + if ( + Array.isArray(model.supported_endpoints) && + model.supported_endpoints.length > 0 && + !model.supported_endpoints.includes("chat") + ) { + return false; + } + + if ( + Array.isArray(model.output_modalities) && + model.output_modalities.length > 0 && + !model.output_modalities.includes("text") + ) { + return false; + } + + return true; +} + +function getCatalogModelId(model: OpenAiCatalogModel) { + return model.id || model.name || model.root || "unknown"; +} + +function normalizeArchitectureKey(value: string) { + const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); + return normalized || "model"; +} + +function normalizeArchitectureSource(value: string) { + const normalized = value.trim().toLowerCase(); + if (normalized === "cx") return "codex"; + if (normalized === "gh") return "github"; + return value; +} + +function getRequestedModelName(payload: unknown): string | null { + if (!payload || typeof payload !== "object") return null; + const record = payload as Record; + const candidate = record.name ?? record.model; + return typeof candidate === "string" && candidate.trim().length > 0 ? candidate.trim() : null; +} + +function getOllamaModelFamily(model: OpenAiCatalogModel, canonicalFamily?: string | null) { + const rawModelId = getCatalogModelId(model).trim(); + const { baseModelId } = parseVscodeServiceTierVariantModelId(rawModelId); + const modelFamily = baseModelId.includes("/") ? baseModelId.split("/").slice(1).join("/") : baseModelId; + + if (modelFamily) { + return modelFamily; + } + + if (canonicalFamily && canonicalFamily.trim().length > 0) { + return canonicalFamily.trim(); + } + + return typeof model.owned_by === "string" && model.owned_by.trim().length > 0 + ? model.owned_by.trim() + : "omniroute"; +} + +function matchesRequestedModel(model: OpenAiCatalogModel, requestedName: string): boolean { + const canonicalMetadata = getCanonicalModelMetadata({ + provider: model.owned_by || null, + model: model.root || model.id || model.name || null, + }); + const family = getOllamaModelFamily(model, canonicalMetadata?.metadata.family || null); + const actualModelId = getCatalogModelId(model); + + return [ + model.id, + model.name, + model.root, + canonicalMetadata?.qualifiedId, + canonicalMetadata?.model, + ...getFamilyFirstModelCandidates(actualModelId, family), + ].some((value) => value === requestedName); +} + +function buildCapabilities(model: OpenAiCatalogModel): string[] { + const capabilities = ["completion"]; + if (model.capabilities?.vision) capabilities.push("vision"); + if (model.capabilities?.tool_calling) capabilities.push("tools"); + if (model.capabilities?.reasoning || model.capabilities?.thinking) capabilities.push("thinking"); + return capabilities; +} + +function buildShowPayload(model: OpenAiCatalogModel, responseModelId?: string) { + const actualModelId = getCatalogModelId(model); + const displayName = getVscodeModelDisplayName(model); + const canonicalMetadata = getCanonicalModelMetadata({ + provider: model.owned_by || null, + model: model.root || model.id || model.name || null, + }); + const family = getOllamaModelFamily(model, canonicalMetadata?.metadata.family || null); + const modelId = responseModelId || getFamilyFirstPublishedModelId(actualModelId, family); + const architectureSource = + normalizeArchitectureSource( + canonicalMetadata?.providerAlias || canonicalMetadata?.provider || model.owned_by || family || "model" + ); + const architecture = normalizeArchitectureKey( + architectureSource + ); + const reasoningEffortValues = getReasoningEffortValues(model as VscodeCatalogModel); + const selectedReasoningEffort = reasoningEffortValues + ? inferSelectedReasoningEffort(model as VscodeCatalogModel, reasoningEffortValues) || "none" + : undefined; + const defaultReasoningEffort = reasoningEffortValues + ? getDefaultReasoningEffort(model as VscodeCatalogModel, reasoningEffortValues) + : undefined; + const supportedReasoningEfforts = + reasoningEffortValues && reasoningEffortValues.length > 0 + ? buildSupportedReasoningEfforts(reasoningEffortValues) + : undefined; + const configSchema = + reasoningEffortValues && defaultReasoningEffort + ? buildReasoningConfigSchema(reasoningEffortValues, defaultReasoningEffort) + : undefined; + let modelCapabilities = model.capabilities ? { ...model.capabilities } : undefined; + + if (reasoningEffortValues) { + modelCapabilities = modelCapabilities || {}; + Object.assign(modelCapabilities, { + reasoning: true, + thinking: true, + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + }); + } + + return { + model: modelId, + remote_model: displayName, + ...(reasoningEffortValues + ? { + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + } + : {}), + license: "proprietary", + modelfile: `FROM ${modelId}`, + parameters: "", + template: "", + details: { + parent_model: model.root || actualModelId || "", + format: "openai", + family, + families: [family], + parameter_size: "unknown", + quantization_level: "dynamic", + ...(reasoningEffortValues + ? { + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + } + : {}), + }, + model_info: { + "general.architecture": architecture, + "general.basename": displayName, + ...(typeof model.context_length === "number" ? { context_length: model.context_length } : {}), + ...(typeof model.context_length === "number" + ? { [`${architecture}.context_length`]: model.context_length } + : {}), + ...(typeof model.max_output_tokens === "number" + ? { max_output_tokens: model.max_output_tokens } + : {}), + ...(Array.isArray(model.input_modalities) + ? { input_modalities: model.input_modalities } + : {}), + ...(Array.isArray(model.output_modalities) + ? { output_modalities: model.output_modalities } + : {}), + ...(Array.isArray(model.supported_endpoints) + ? { supported_endpoints: model.supported_endpoints } + : {}), + ...(modelCapabilities ? { capabilities: modelCapabilities } : {}), + ...(reasoningEffortValues + ? { + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + } + : {}), + }, + capabilities: buildCapabilities(model), + }; +} + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function POST( + request: Request, + { params }: { params?: Promise<{ token: string }> | { token: string } } = {} +) { + const resolvedParams = params ? await params : undefined; + const authorizedRequest = withPathTokenApiKey(request, resolvedParams?.token); + const payload = await request + .clone() + .json() + .catch(() => null); + const requestedName = getRequestedModelName(payload); + + if (!requestedName) { + return Response.json( + { + error: "Model name is required", + }, + { + status: 400, + headers: { + ...CORS_HEADERS, + }, + } + ); + } + + const catalogResponse = await getUnifiedModelsResponse(authorizedRequest, { + "Content-Type": "application/json", + ...CORS_HEADERS, + }); + const catalogBody = (await catalogResponse.json()) as { data?: OpenAiCatalogModel[] }; + + if (!catalogResponse.ok) { + return Response.json(catalogBody, { + status: catalogResponse.status, + headers: { + ...CORS_HEADERS, + }, + }); + } + + const expandedModels = Array.isArray(catalogBody.data) + ? expandVscodeServiceTierModels(catalogBody.data.filter(isUsableChatModel)) + : []; + + const model = Array.isArray(expandedModels) + ? expandedModels.find((entry) => matchesRequestedModel(entry, requestedName)) + : undefined; + + if (!model) { + return Response.json( + { + error: `Model not found: ${requestedName}`, + }, + { + status: 404, + headers: { + ...CORS_HEADERS, + }, + } + ); + } + + return Response.json(buildShowPayload(model, requestedName), { + headers: { + ...CORS_HEADERS, + }, + }); +} diff --git a/src/app/api/v1/vscode/[token]/api/tags/route.ts b/src/app/api/v1/vscode/[token]/api/tags/route.ts new file mode 100644 index 0000000000..81273c909b --- /dev/null +++ b/src/app/api/v1/vscode/[token]/api/tags/route.ts @@ -0,0 +1,257 @@ +import { getUnifiedModelsResponse } from "@/app/api/v1/models/catalog"; +import { getProviderConnections } from "@/lib/db/providers"; +import { getCanonicalModelMetadata } from "@/lib/modelMetadataRegistry"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { + buildReasoningConfigSchema, + buildSupportedReasoningEfforts, + getDefaultReasoningEffort, + getReasoningVariantBaseModelId, + getReasoningEffortValues, + inferSelectedReasoningEffort, + type VscodeCatalogModel, +} from "@/app/api/v1/vscode/[token]/reasoningMetadata"; +import { getVscodeModelGroupingKey } from "@/app/api/v1/vscode/[token]/modelPresentation"; +import { + expandVscodeServiceTierModels, + getVscodeServiceTierVariantModelId, + parseVscodeServiceTierVariantModelId, +} from "@/app/api/v1/vscode/[token]/serviceTierVariants"; +import { getFamilyFirstPublishedModelId } from "@/app/api/v1/vscode/[token]/familyFirstModelIds"; +import { withPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest"; + +type OpenAiCatalogModel = { + id?: string; + name?: string; + root?: string; + parent?: string | null; + owned_by?: string; + type?: string; + api_format?: string; + context_length?: number; + output_modalities?: string[]; + supported_endpoints?: string[]; +}; + +function getModelName(model: OpenAiCatalogModel) { + return model.id || model.name || model.root || ""; +} + +function isCodexOwnedModel(model: OpenAiCatalogModel) { + const owner = typeof model.owned_by === "string" ? model.owned_by.trim().toLowerCase() : ""; + const modelName = getModelName(model).toLowerCase(); + + return owner === "codex" || modelName.startsWith("cx/") || modelName.startsWith("codex/"); +} + +async function selectPreferredModels(models: OpenAiCatalogModel[]) { + const activeConnections = (await getProviderConnections({ isActive: true })) as Array<{ + provider?: string; + }>; + const activeProviders = new Set( + activeConnections + .map((connection) => + typeof connection.provider === "string" ? connection.provider.trim().toLowerCase() : "" + ) + .filter(Boolean) + ); + + const preferCodexOnly = + activeProviders.size > 0 && Array.from(activeProviders).every((provider) => provider === "codex"); + if (!preferCodexOnly) return models; + + const codexModels = models.filter(isCodexOwnedModel); + return codexModels.length > 0 ? codexModels : models; +} + +function isUsableChatModel(model: OpenAiCatalogModel) { + if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { + return false; + } + if (typeof model.parent === "string" && model.parent.length > 0) return false; + if (typeof model.type === "string" && model.type !== "chat") return false; + + const apiFormat = typeof model.api_format === "string" ? model.api_format : "chat-completions"; + if (apiFormat !== "chat-completions") return false; + + if ( + Array.isArray(model.supported_endpoints) && + model.supported_endpoints.length > 0 && + !model.supported_endpoints.includes("chat") + ) { + return false; + } + + if ( + Array.isArray(model.output_modalities) && + model.output_modalities.length > 0 && + !model.output_modalities.includes("text") + ) { + return false; + } + + return true; +} + +function getOllamaModelFamily(model: OpenAiCatalogModel, canonicalFamily?: string | null) { + const rawModelId = getModelName(model).trim(); + const tierParsedModel = parseVscodeServiceTierVariantModelId(rawModelId); + const baseModelId = getReasoningVariantBaseModelId(tierParsedModel.baseModelId); + const modelFamily = baseModelId.includes("/") ? baseModelId.split("/").slice(1).join("/") : baseModelId; + + if (modelFamily) { + return modelFamily; + } + + if (canonicalFamily && canonicalFamily.trim().length > 0) { + return canonicalFamily.trim(); + } + + return typeof model.owned_by === "string" && model.owned_by.trim().length > 0 + ? model.owned_by.trim() + : "omniroute"; +} + +function toOllamaTagModel(model: OpenAiCatalogModel) { + const actualModelId = model.id || model.root || "unknown"; + const canonicalMetadata = getCanonicalModelMetadata({ + provider: model.owned_by || null, + model: model.root || model.id || model.name || null, + }); + const family = getOllamaModelFamily(model, canonicalMetadata?.metadata.family || null); + const modelId = getFamilyFirstPublishedModelId(actualModelId, family); + const contextLength = typeof model.context_length === "number" ? model.context_length : 0; + const reasoningEffortValues = getReasoningEffortValues(model as VscodeCatalogModel); + const selectedReasoningEffort = reasoningEffortValues + ? inferSelectedReasoningEffort(model as VscodeCatalogModel, reasoningEffortValues) || "none" + : undefined; + const defaultReasoningEffort = reasoningEffortValues + ? getDefaultReasoningEffort(model as VscodeCatalogModel, reasoningEffortValues) + : undefined; + const supportedReasoningEfforts = + reasoningEffortValues && reasoningEffortValues.length > 0 + ? buildSupportedReasoningEfforts(reasoningEffortValues) + : undefined; + const configSchema = + reasoningEffortValues && defaultReasoningEffort + ? buildReasoningConfigSchema(reasoningEffortValues, defaultReasoningEffort) + : undefined; + + return { + name: modelId, + model: modelId, + modified_at: "2026-01-01T00:00:00Z", + size: 0, + digest: `omniroute:${modelId}`, + ...(reasoningEffortValues + ? { + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + } + : {}), + details: { + format: "openai", + family, + parameter_size: contextLength > 0 ? `${contextLength} ctx` : "unknown", + quantization_level: "dynamic", + ...(reasoningEffortValues + ? { + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + } + : {}), + }, + }; +} + +function filterCanonicalTagModels(models: OpenAiCatalogModel[]) { + const allModelIds = new Set(models.map((model) => (model.id || model.root || model.name || "").trim()).filter(Boolean)); + const groupedModels = new Map(); + const orderedGroupKeys: string[] = []; + + for (const model of models) { + const modelId = (model.id || model.root || model.name || "").trim(); + if (!modelId) continue; + + const tierParsedModel = parseVscodeServiceTierVariantModelId(modelId); + const baseModelId = getReasoningVariantBaseModelId(tierParsedModel.baseModelId); + const canonicalModelId = tierParsedModel.serviceTier + ? getVscodeServiceTierVariantModelId(baseModelId, tierParsedModel.serviceTier) + : baseModelId; + if (canonicalModelId !== modelId && allModelIds.has(canonicalModelId)) { + continue; + } + + const groupKey = tierParsedModel.serviceTier + ? canonicalModelId + : getVscodeModelGroupingKey(model) || canonicalModelId; + const current = groupedModels.get(groupKey); + if (!current) { + groupedModels.set(groupKey, model); + orderedGroupKeys.push(groupKey); + continue; + } + + const currentId = (current.id || current.root || current.name || "").trim(); + if (currentId !== groupKey && modelId === canonicalModelId) { + groupedModels.set(groupKey, model); + } + } + + return orderedGroupKeys.map((groupKey) => groupedModels.get(groupKey)).filter(Boolean) as OpenAiCatalogModel[]; +} + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function GET( + request: Request, + { params }: { params?: Promise<{ token: string }> | { token: string } } = {} +) { + const resolvedParams = params ? await params : undefined; + const authorizedRequest = withPathTokenApiKey(request, resolvedParams?.token); + const response = await getUnifiedModelsResponse(authorizedRequest, { + "Content-Type": "application/json", + ...CORS_HEADERS, + }); + const body = (await response.json()) as { data?: OpenAiCatalogModel[] }; + + if (!response.ok) { + return Response.json(body, { + status: response.status, + headers: { + ...CORS_HEADERS, + }, + }); + } + + const usableModels = Array.isArray(body.data) ? body.data.filter(isUsableChatModel) : []; + const preferredModels = filterCanonicalTagModels( + expandVscodeServiceTierModels(await selectPreferredModels(usableModels)) + ); + const models = preferredModels.map(toOllamaTagModel); + + return Response.json( + { + models, + }, + { + headers: { + ...CORS_HEADERS, + }, + } + ); +} diff --git a/src/app/api/v1/vscode/[token]/api/version/route.ts b/src/app/api/v1/vscode/[token]/api/version/route.ts new file mode 100644 index 0000000000..89cf0622d1 --- /dev/null +++ b/src/app/api/v1/vscode/[token]/api/version/route.ts @@ -0,0 +1,20 @@ +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; + +const OLLAMA_COMPAT_VERSION = "0.6.4"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function GET() { + return Response.json( + { + version: OLLAMA_COMPAT_VERSION, + }, + { + headers: { + ...CORS_HEADERS, + }, + } + ); +} \ No newline at end of file diff --git a/src/app/api/v1/vscode/[token]/chat/completions/route.ts b/src/app/api/v1/vscode/[token]/chat/completions/route.ts new file mode 100644 index 0000000000..b243f2fe8b --- /dev/null +++ b/src/app/api/v1/vscode/[token]/chat/completions/route.ts @@ -0,0 +1,10 @@ +import { POST as basePost, OPTIONS } from "@/app/api/v1/chat/completions/route"; +import { rewriteVscodeServiceTierRequest } from "@/app/api/v1/vscode/[token]/serviceTierVariants"; +import { withPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest"; + +export { OPTIONS }; + +export async function POST(request: Request) { + const authorizedRequest = withPathTokenApiKey(request); + return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest)); +} diff --git a/src/app/api/v1/vscode/[token]/combos/route.ts b/src/app/api/v1/vscode/[token]/combos/route.ts new file mode 100644 index 0000000000..671a662cca --- /dev/null +++ b/src/app/api/v1/vscode/[token]/combos/route.ts @@ -0,0 +1,41 @@ +/** + * VS Code Combos endpoint — re-export base Ollama-compatible routes + * and intercept GET to return combo metadata. + * + * The VS Code extension expects a standard Ollama server at the base URL, + * so we re-export /api/version, /api/tags, etc. from the [token] parent route. + */ +import { getCombos } from "@/lib/db/combos"; +import { projectCombo, type PublicCombo } from "@/app/api/v1/combos/projectCombo"; + +// Re-export Ollama-compatible endpoints from the parent [token] route +// so VS Code can validate the server version and list models normally +export { OPTIONS } from "@/app/api/v1/vscode/[token]/route"; +export async function GET(request: Request) { + // If client requests /api/version or other Ollama endpoints, delegate to parent + const url = new URL(request.url); + if (url.pathname.includes("/api/version") || url.pathname.includes("/api/tags")) { + const { GET: parentGET } = await import("@/app/api/v1/vscode/[token]/route"); + return parentGET(request); + } + + // Default: return combos metadata + try { + const combos = await getCombos(); + const data = (Array.isArray(combos) ? combos : []) + .map((combo) => projectCombo(combo as Record)) + .filter((combo): combo is PublicCombo => combo !== null); + + return new Response(JSON.stringify({ object: "list", data, combos: data }), { + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); + } catch (error) { + return new Response(JSON.stringify({ error: "Failed to fetch combos" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +} diff --git a/src/app/api/v1/vscode/[token]/familyFirstModelIds.ts b/src/app/api/v1/vscode/[token]/familyFirstModelIds.ts new file mode 100644 index 0000000000..c5a2484761 --- /dev/null +++ b/src/app/api/v1/vscode/[token]/familyFirstModelIds.ts @@ -0,0 +1,85 @@ +const FAMILY_FIRST_MODEL_PATTERN = /^((?:gpt-[a-z0-9._-]+|claude[a-z0-9._-]*))(?:__provider_([a-z0-9-]+))(?:__tier_(priority|flex))?$/i; +const TIER_SUFFIX_PATTERN = /(__tier_(?:priority|flex))$/i; + +function normalizeFamily(value: string | null | undefined) { + return typeof value === "string" ? value.trim() : ""; +} + +function splitActualModelId(modelId: string) { + const trimmedModelId = modelId.trim(); + const slashIndex = trimmedModelId.indexOf("/"); + if (slashIndex <= 0 || slashIndex === trimmedModelId.length - 1) { + return null; + } + + return { + providerPrefix: trimmedModelId.slice(0, slashIndex), + providerModelId: trimmedModelId.slice(slashIndex + 1), + }; +} + +function extractTierSuffix(modelId: string) { + const match = modelId.match(TIER_SUFFIX_PATTERN); + return match?.[1] || ""; +} + +function stripTierSuffix(modelId: string) { + return modelId.replace(TIER_SUFFIX_PATTERN, ""); +} + +function isFamilyFirstEligibleFamily(family: string) { + const normalized = family.toLowerCase(); + return normalized.startsWith("gpt-") || normalized.startsWith("claude"); +} + +export function getFamilyFirstPublishedModelId(actualModelId: string, family: string | null | undefined) { + const normalizedFamily = normalizeFamily(family); + if (!normalizedFamily || !isFamilyFirstEligibleFamily(normalizedFamily)) { + return actualModelId; + } + + const parts = splitActualModelId(actualModelId); + if (!parts) { + return actualModelId; + } + + const tierSuffix = extractTierSuffix(parts.providerModelId); + const providerModelBase = stripTierSuffix(parts.providerModelId); + if (providerModelBase !== normalizedFamily) { + return actualModelId; + } + + return `${normalizedFamily}__provider_${parts.providerPrefix}${tierSuffix}`; +} + +export function resolveFamilyFirstPublishedModelId(modelId: string | null | undefined) { + const trimmedModelId = typeof modelId === "string" ? modelId.trim() : ""; + if (!trimmedModelId) { + return trimmedModelId; + } + + const match = trimmedModelId.match(FAMILY_FIRST_MODEL_PATTERN); + if (!match) { + return trimmedModelId; + } + + const [, family, providerPrefix, serviceTier] = match; + const tierSuffix = serviceTier ? `__tier_${serviceTier.toLowerCase()}` : ""; + return `${providerPrefix}/${family}${tierSuffix}`; +} + +export function getFamilyFirstModelCandidates(actualModelId: string, family: string | null | undefined) { + const normalizedFamily = normalizeFamily(family); + const candidates = new Set([actualModelId]); + const publishedModelId = getFamilyFirstPublishedModelId(actualModelId, normalizedFamily); + if (publishedModelId !== actualModelId) { + candidates.add(publishedModelId); + } + + if (normalizedFamily && isFamilyFirstEligibleFamily(normalizedFamily)) { + const tierSuffix = extractTierSuffix(actualModelId); + candidates.add(`${normalizedFamily}${tierSuffix}`); + } + + return [...candidates]; +} \ No newline at end of file diff --git a/src/app/api/v1/vscode/[token]/modelPresentation.ts b/src/app/api/v1/vscode/[token]/modelPresentation.ts new file mode 100644 index 0000000000..47b2658c17 --- /dev/null +++ b/src/app/api/v1/vscode/[token]/modelPresentation.ts @@ -0,0 +1,178 @@ +import { parseModel } from "@omniroute/open-sse/services/model"; +import { + getCanonicalModelMetadata, + type CanonicalModelMetadata, +} from "@/lib/modelMetadataRegistry"; +import { + getVscodeServiceTierVariantSuffix, + parseVscodeServiceTierVariantModelId, + supportsVscodeServiceTierVariants, +} from "@/app/api/v1/vscode/[token]/serviceTierVariants"; +import { getReasoningVariantBaseModelId } from "@/app/api/v1/vscode/[token]/reasoningMetadata"; +import { resolveFamilyFirstPublishedModelId } from "@/app/api/v1/vscode/[token]/familyFirstModelIds"; +type VscodeCatalogModel = { + id?: string; + name?: string; + root?: string; + owned_by?: string; +}; + +const PROVIDER_NAME_OVERRIDES: Record = { + codex: "Codex", + cx: "Codex", + github: "GitHub", + gh: "GitHub", + "gemini-cli": "Gemini", + gemini: "Gemini", +}; + +function tokenize(value: string) { + return value + .toLowerCase() + .split(/[^a-z0-9]+/i) + .map((part) => part.trim()) + .filter((part) => part.length >= 4); +} + +function getProviderPrefix(metadata: CanonicalModelMetadata | null) { + const providerKey = metadata?.providerAlias || metadata?.provider || ""; + if (providerKey && PROVIDER_NAME_OVERRIDES[providerKey]) { + return PROVIDER_NAME_OVERRIDES[providerKey]; + } + + const providerLabel = metadata?.providerLabel?.trim() || null; + if (!providerLabel) { + return null; + } + if (/codex/i.test(providerLabel)) { + return "Codex"; + } + if (/github/i.test(providerLabel)) { + return "GitHub"; + } + if (/gemini/i.test(providerLabel)) { + return "Gemini"; + } + + return providerLabel; +} + +function normalizeDisplayNameBranding(displayName: string) { + return displayName + .replace(/^OpenAI\s+Codex\b/i, "Codex") + .replace(/^GitHub\s+Copilot\b/i, "GitHub") + .trim(); +} + +function stripLeadingProviderPrefix(displayName: string, providerPrefix: string | null) { + if (!providerPrefix) { + return displayName; + } + + const escapedProviderPrefix = providerPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return displayName.replace(new RegExp(`^${escapedProviderPrefix}\\s+`, "i"), "").trim(); +} + +function looksLikeTechnicalModelName(value: string) { + return /\/|__provider_|__tier_|^[a-z0-9-]+\/[a-z0-9._-]+$/i.test(value); +} + +function humanizeModelIdentifier(modelId: string) { + const identifier = (modelId.split("/").pop() || modelId).trim(); + if (!identifier) { + return identifier; + } + + return identifier + .split(/[-_]+/) + .filter(Boolean) + .map(part => { + if (/^gpt$/i.test(part)) return "GPT"; + if (/^[0-9]+(?:\.[0-9]+)*$/.test(part)) return part; + if (/^[a-z][0-9]$/i.test(part)) return part.toUpperCase(); + return part.charAt(0).toUpperCase() + part.slice(1); + }) + .join(" "); +} + +function resolveFriendlyBaseDisplayName( + rawModelId: string, + metadata: CanonicalModelMetadata | null, + fallbackValue: string +) { + const normalizedFallback = normalizeDisplayNameBranding(fallbackValue.trim()); + if (normalizedFallback && !looksLikeTechnicalModelName(normalizedFallback)) { + return normalizedFallback; + } + + const normalizedModelId = resolveFamilyFirstPublishedModelId(rawModelId); + const parsedTierModel = parseVscodeServiceTierVariantModelId(normalizedModelId); + const canonicalBaseModelId = getReasoningVariantBaseModelId(parsedTierModel.baseModelId); + const parsed = parseModel(canonicalBaseModelId, ""); + const providerModelId = + parsed.model || + (canonicalBaseModelId.includes("/") + ? canonicalBaseModelId.split("/").slice(1).join("/") + : canonicalBaseModelId) || + fallbackValue; + + return humanizeModelIdentifier(providerModelId); +} + +function prefixDisplayName(displayName: string, providerPrefix: string | null) { + const normalizedProviderPrefix = providerPrefix?.trim() || null; + const normalizedDisplayName = normalizeDisplayNameBranding(displayName); + + if (!normalizedProviderPrefix) return normalizedDisplayName; + + const providerTokens = tokenize(normalizedProviderPrefix); + if (providerTokens.length === 0) return normalizedDisplayName; + + const displayNameLower = normalizedDisplayName.toLowerCase(); + if (providerTokens.some((token) => displayNameLower.includes(token))) { + return normalizedDisplayName; + } + + return `${normalizedProviderPrefix} ${stripLeadingProviderPrefix(normalizedDisplayName, normalizedProviderPrefix)}`.trim(); +} + +export function resolveVscodeModelMetadata(model: VscodeCatalogModel) { + const rawModelId = model.id || model.root || model.name || ""; + const normalizedModelId = resolveFamilyFirstPublishedModelId(rawModelId); + const parsedTierModel = parseVscodeServiceTierVariantModelId(normalizedModelId); + const canonicalBaseModelId = getReasoningVariantBaseModelId(parsedTierModel.baseModelId); + const parsed = parseModel(canonicalBaseModelId, ""); + const provider = parsed.provider || model.owned_by || undefined; + const providerModel = + parsed.model || + (canonicalBaseModelId.includes("/") + ? canonicalBaseModelId.split("/").slice(1).join("/") + : canonicalBaseModelId) || + model.root || + model.id || + model.name || + undefined; + + return providerModel && provider + ? getCanonicalModelMetadata({ provider, model: providerModel }) + : providerModel + ? getCanonicalModelMetadata({ model: providerModel }) + : null; +} + +export function getVscodeModelDisplayName(model: VscodeCatalogModel) { + const rawModelId = model.id || model.root || model.name || ""; + const { serviceTier } = parseVscodeServiceTierVariantModelId(rawModelId); + const metadata = resolveVscodeModelMetadata(model); + const displayName = metadata?.displayName || model.name || model.id || model.root || "unknown"; + const prefixedDisplayName = prefixDisplayName(displayName, getProviderPrefix(metadata)); + const shouldShowTierSuffix = Boolean(serviceTier) || supportsVscodeServiceTierVariants(model); + return shouldShowTierSuffix + ? `${prefixedDisplayName} (${getVscodeServiceTierVariantSuffix(serviceTier)})` + : prefixedDisplayName; +} + +export function getVscodeModelGroupingKey(model: VscodeCatalogModel) { + const metadata = resolveVscodeModelMetadata(model); + return metadata?.qualifiedId || metadata?.model || model.id || model.name || model.root || ""; +} diff --git a/src/app/api/v1/vscode/[token]/models/route.ts b/src/app/api/v1/vscode/[token]/models/route.ts new file mode 100644 index 0000000000..946a8cc866 --- /dev/null +++ b/src/app/api/v1/vscode/[token]/models/route.ts @@ -0,0 +1,424 @@ +import { getUnifiedModelsResponse } from "@/app/api/v1/models/catalog"; +import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; +import { getCanonicalModelMetadata } from "@/lib/modelMetadataRegistry"; +import { + buildReasoningConfigSchema, + buildSupportedReasoningEfforts, + getDefaultReasoningEffort, + getCatalogModelName, + formatReasoningEffortLabel, + getReasoningEffortValues, + getReasoningVariantBaseModelId, + type VscodeCatalogModel, + type VscodeModelConfigSchema, +} from "@/app/api/v1/vscode/[token]/reasoningMetadata"; +import { + getVscodeModelDisplayName, + resolveVscodeModelMetadata, + getVscodeModelGroupingKey, +} from "@/app/api/v1/vscode/[token]/modelPresentation"; +import { withPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest"; +import { + expandVscodeServiceTierModels, + getVscodeServiceTierVariantSuffix, + getVscodeServiceTierVariantModelId, + parseVscodeServiceTierVariantModelId, +} from "@/app/api/v1/vscode/[token]/serviceTierVariants"; +import { getFamilyFirstPublishedModelId } from "@/app/api/v1/vscode/[token]/familyFirstModelIds"; + +type CatalogModelEntry = { + id?: string; + name?: string; + root?: string; + owned_by?: string; + parent?: string | null; + type?: string; + api_format?: string; + context_length?: number; + max_input_tokens?: number; + max_output_tokens?: number; + supported_endpoints?: string[]; + output_modalities?: string[]; + capabilities?: Record; +}; + +type VscodeImportModel = CatalogModelEntry & { + url?: string; + toolCalling?: boolean; + vision?: boolean; + maxInputTokens?: number; + maxOutputTokens?: number; + family?: string; + supportsReasoningEffort?: string[]; + supportedReasoningEfforts?: string[]; + defaultReasoningEffort?: string; + configurationSchema?: VscodeModelConfigSchema; + configSchema?: VscodeModelConfigSchema; +}; + +type VscodeModelsCatalogResponse = { + status: number; + headers: Record; + body: { data?: CatalogModelEntry[]; [key: string]: unknown }; +}; + +const VSCODE_CATALOG_CACHE_HEADERS = { + "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0", + Pragma: "no-cache", + Expires: "0", +} as const; + +type EnrichModelForVscodeOptions = { + preserveNativeId?: boolean; +}; + +function isUsableChatModel(model: CatalogModelEntry) { + if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { + return false; + } + if (typeof model.parent === "string" && model.parent.length > 0) return false; + if (typeof model.type === "string" && model.type !== "chat") return false; + if (typeof model.api_format === "string" && model.api_format !== "chat-completions") { + return false; + } + if ( + Array.isArray(model.supported_endpoints) && + model.supported_endpoints.length > 0 && + !model.supported_endpoints.includes("chat") + ) { + return false; + } + if ( + Array.isArray(model.output_modalities) && + model.output_modalities.length > 0 && + !model.output_modalities.includes("text") + ) { + return false; + } + + return true; +} + +function getModelImportReasoningEffortValues(model: VscodeCatalogModel, reasoningEffortValues: string[]) { + const providerId = + (model.owned_by || "").trim() || + (model.id || model.name || model.root || "").split("/")[0] || + ""; + if (providerId === "github" || providerId === "gh") { + return reasoningEffortValues.filter((value) => value !== "xhigh"); + } + return reasoningEffortValues; +} + +function getVscodeImportFamily(model: CatalogModelEntry, canonicalFamily?: string | null) { + const rawModelId = (model.root || model.id || model.name || "").trim(); + const tierParsedModel = parseVscodeServiceTierVariantModelId(rawModelId); + const baseModelId = getReasoningVariantBaseModelId(tierParsedModel.baseModelId); + const modelFamily = baseModelId.includes("/") ? baseModelId.split("/").slice(1).join("/") : baseModelId; + + if (modelFamily) { + return modelFamily; + } + + if (canonicalFamily && canonicalFamily.trim().length > 0) { + return canonicalFamily.trim(); + } + + return typeof model.owned_by === "string" && model.owned_by.trim().length > 0 + ? model.owned_by.trim() + : undefined; +} + +export function getVscodeRawModelDisplayName(model: CatalogModelEntry) { + const actualModelId = (model.id || model.name || model.root || "").trim(); + const canonicalMetadata = resolveVscodeModelMetadata(model); + const { baseModelId } = parseVscodeServiceTierVariantModelId(actualModelId); + const displayBaseModelId = getReasoningVariantBaseModelId(baseModelId); + const baseDisplayName = getVscodeModelDisplayName({ + ...model, + id: displayBaseModelId, + name: displayBaseModelId, + root: displayBaseModelId, + }).replace(/\s+\(Default\)$/u, ""); + const providerKey = canonicalMetadata?.providerAlias || canonicalMetadata?.provider || ""; + const providerPrefix = providerKey === "codex" || providerKey === "cx" + ? "Codex" + : providerKey === "github" || providerKey === "gh" + ? "GitHub" + : canonicalMetadata?.providerLabel || null; + const prefixedDisplayName = providerPrefix && !baseDisplayName.toLowerCase().includes(providerPrefix.toLowerCase()) + ? `${providerPrefix} ${baseDisplayName}`.trim() + : baseDisplayName; + const { serviceTier } = parseVscodeServiceTierVariantModelId(actualModelId); + const reasoningEffortValues = getReasoningEffortValues(model as VscodeCatalogModel); + const selectedReasoningEffort = reasoningEffortValues + ? getCatalogModelName(model as VscodeCatalogModel).match(/-(xhigh|high|medium|low|none)$/i)?.[1]?.toLowerCase() + : undefined; + + const suffixes: string[] = []; + if (selectedReasoningEffort && selectedReasoningEffort !== "none") { + suffixes.push(formatReasoningEffortLabel(selectedReasoningEffort)); + } + if (serviceTier) { + suffixes.push(getVscodeServiceTierVariantSuffix(serviceTier)); + } + if (suffixes.length === 0) { + return prefixedDisplayName; + } + if (suffixes.length === 1) { + return `${prefixedDisplayName} (${suffixes[0]})`; + } + const [first, ...rest] = suffixes; + return `${prefixedDisplayName} (${first}) ${rest.map((suffix) => `(${suffix})`).join(" ")}`; +} + +export function enrichModelForVscode( + model: CatalogModelEntry, + request: Request, + options: EnrichModelForVscodeOptions = {} +): VscodeImportModel { + if (!isUsableChatModel(model)) return model; + + const requestUrl = new URL(request.url); + const tokenBasePath = requestUrl.pathname.replace(/\/models(?:\/raw)?\/?$/, ""); + const tokenBaseUrl = `${requestUrl.origin}${tokenBasePath}`; + const canonicalMetadata = getCanonicalModelMetadata({ + provider: model.owned_by || null, + model: model.root || model.id || model.name || null, + }); + const family = getVscodeImportFamily(model, canonicalMetadata?.metadata.family || null); + const resolvedCapabilities = getResolvedModelCapabilities(model.id || model.name || ""); + const reasoningEffortValues = + resolvedCapabilities.reasoning === true + ? getReasoningEffortValues(model as VscodeCatalogModel) + : undefined; + const modelImportReasoningEffortValues = + reasoningEffortValues && reasoningEffortValues.length > 0 + ? getModelImportReasoningEffortValues(model as VscodeCatalogModel, reasoningEffortValues) + : undefined; + const defaultReasoningEffort = reasoningEffortValues + ? getDefaultReasoningEffort(model as VscodeCatalogModel, reasoningEffortValues) + : undefined; + const supportedReasoningEfforts = + reasoningEffortValues && reasoningEffortValues.length > 0 + ? buildSupportedReasoningEfforts(reasoningEffortValues) + : undefined; + const configSchema = + reasoningEffortValues && defaultReasoningEffort + ? buildReasoningConfigSchema(reasoningEffortValues, defaultReasoningEffort) + : undefined; + const actualModelId = (model.id || model.name || model.root || "").trim(); + const publishedModelId = getFamilyFirstPublishedModelId(actualModelId, family || null); + const resolvedModelId = options.preserveNativeId ? actualModelId : publishedModelId; + const presentationModel = { + ...model, + ...(resolvedModelId ? { id: resolvedModelId } : {}), + }; + + // Raw route: return a bare provider-native entry — no VS Code presentation + // fields (url/toolCalling/vision/maxInputTokens/family/reasoning). Those are + // only meaningful for the grouped/family-first surfaces. + if (options.preserveNativeId) { + return { + ...presentationModel, + name: getVscodeRawModelDisplayName(presentationModel), + }; + } + + return { + ...presentationModel, + name: options.preserveNativeId + ? getVscodeRawModelDisplayName(presentationModel) + : getVscodeModelDisplayName(presentationModel), + url: reasoningEffortValues + ? `${tokenBaseUrl}/responses#models.ai.azure.com` + : `${tokenBaseUrl}/chat/completions#models.ai.azure.com`, + toolCalling: resolvedCapabilities.toolCalling === true, + vision: resolvedCapabilities.supportsVision === true, + maxInputTokens: + model.max_input_tokens || resolvedCapabilities.maxInputTokens || model.context_length, + maxOutputTokens: model.max_output_tokens || resolvedCapabilities.maxOutputTokens, + ...(family ? { family } : {}), + ...(modelImportReasoningEffortValues ? { supportsReasoningEffort: modelImportReasoningEffortValues } : {}), + ...(supportedReasoningEfforts ? { supportedReasoningEfforts } : {}), + ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + }; +} + +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +export function expandVscodeRawModels(models: CatalogModelEntry[]) { + const normalizedModels = models.map((model) => { + const rawModelId = (model.id || model.name || model.root || "").trim(); + if (!rawModelId) { + return model; + } + + const tierParsedModel = parseVscodeServiceTierVariantModelId(rawModelId); + const normalizedBaseModelId = getReasoningVariantBaseModelId(tierParsedModel.baseModelId); + const normalizedModelId = tierParsedModel.serviceTier + ? getVscodeServiceTierVariantModelId(normalizedBaseModelId, tierParsedModel.serviceTier) + : normalizedBaseModelId; + + if (normalizedModelId === rawModelId) { + return model; + } + + return { + ...model, + ...(model.id ? { id: normalizedModelId } : {}), + ...(model.name ? { name: normalizedModelId } : {}), + ...(model.root ? { root: normalizedModelId } : {}), + }; + }); + + const tierExpandedModels = expandVscodeServiceTierModels(normalizedModels); + const expandedModels: CatalogModelEntry[] = []; + + for (const model of tierExpandedModels) { + expandedModels.push(model); + + const reasoningEffortValues = getReasoningEffortValues(model as VscodeCatalogModel); + if (!reasoningEffortValues || reasoningEffortValues.length === 0) { + continue; + } + + const rawModelId = (model.id || model.name || model.root || "").trim(); + if (!rawModelId) { + continue; + } + + const parsedTierModel = parseVscodeServiceTierVariantModelId(rawModelId); + const baseReasoningModelId = getReasoningVariantBaseModelId(parsedTierModel.baseModelId); + + for (const reasoningEffort of reasoningEffortValues) { + if (reasoningEffort === "none") { + continue; + } + + const reasoningBaseModelId = `${baseReasoningModelId}-${reasoningEffort}`; + const reasoningVariantModelId = parsedTierModel.serviceTier + ? getVscodeServiceTierVariantModelId(reasoningBaseModelId, parsedTierModel.serviceTier) + : reasoningBaseModelId; + expandedModels.push({ + ...model, + ...(model.id ? { id: reasoningVariantModelId } : {}), + ...(model.name ? { name: reasoningVariantModelId } : {}), + ...(model.root ? { root: reasoningVariantModelId } : {}), + }); + } + } + + const uniqueModels = new Map(); + + for (const model of expandedModels) { + const uniqueKey = (model.id || model.name || model.root || "").trim(); + if (!uniqueKey || uniqueModels.has(uniqueKey)) { + continue; + } + + uniqueModels.set(uniqueKey, model); + } + + return Array.from(uniqueModels.values()); +} + +export async function getVscodeModelsCatalogResponse( + request: Request +): Promise { + const response = await getUnifiedModelsResponse(request); + const body = (await response.json()) as { data?: CatalogModelEntry[] }; + return { + status: response.status, + headers: { + ...Object.fromEntries(response.headers.entries()), + ...VSCODE_CATALOG_CACHE_HEADERS, + }, + body: { + ...body, + data: Array.isArray(body.data) ? body.data.filter(isUsableChatModel) : body.data, + }, + }; +} + +export async function GET( + request: Request, + { params }: { params?: Promise<{ token: string }> | { token: string } } = {} +) { + const resolvedParams = params ? await params : undefined; + const authorizedRequest = withPathTokenApiKey(request, resolvedParams?.token); + const catalog = await getVscodeModelsCatalogResponse(authorizedRequest); + const body = catalog.body; + + if (catalog.status < 200 || catalog.status >= 300 || !Array.isArray(body.data)) { + return Response.json(body, { + status: catalog.status, + headers: catalog.headers, + }); + } + + return Response.json( + (() => { + const expandedModels = expandVscodeServiceTierModels(body.data); + const allModelIds = new Set( + expandedModels.map((model) => (model.id || model.name || model.root || "").trim()).filter(Boolean) + ); + const groupedModels = new Map(); + const orderedGroupKeys: string[] = []; + + for (const model of expandedModels) { + const modelId = (model.id || model.name || model.root || "").trim(); + if (!modelId) continue; + + const tierParsedModel = parseVscodeServiceTierVariantModelId(modelId); + const baseModelId = getReasoningVariantBaseModelId(tierParsedModel.baseModelId); + const canonicalModelId = tierParsedModel.serviceTier + ? getVscodeServiceTierVariantModelId(baseModelId, tierParsedModel.serviceTier) + : baseModelId; + if (canonicalModelId !== modelId && allModelIds.has(canonicalModelId)) { + continue; + } + + const groupKey = + tierParsedModel.serviceTier + ? canonicalModelId + : getVscodeModelGroupingKey({ + ...model, + ...(canonicalModelId ? { id: canonicalModelId } : {}), + }) || canonicalModelId; + const current = groupedModels.get(groupKey); + if (!current) { + groupedModels.set(groupKey, model); + orderedGroupKeys.push(groupKey); + continue; + } + + const currentId = (current.id || current.name || current.root || "").trim(); + if (currentId !== groupKey && modelId === canonicalModelId) { + groupedModels.set(groupKey, model); + } + } + + return { + ...body, + data: orderedGroupKeys + .map((groupKey) => groupedModels.get(groupKey)) + .filter(Boolean) + .map((model) => enrichModelForVscode(model as CatalogModelEntry, authorizedRequest)), + }; + })(), + { + status: catalog.status, + headers: catalog.headers, + } + ); +} diff --git a/src/app/api/v1/vscode/[token]/reasoningMetadata.ts b/src/app/api/v1/vscode/[token]/reasoningMetadata.ts new file mode 100644 index 0000000000..e7996a8883 --- /dev/null +++ b/src/app/api/v1/vscode/[token]/reasoningMetadata.ts @@ -0,0 +1,175 @@ +import { parseModel } from "@omniroute/open-sse/services/model"; +import { supportsXHighEffort } from "@omniroute/open-sse/config/providerModels"; +import { stripVscodeServiceTierVariantModelId } from "@/app/api/v1/vscode/[token]/serviceTierVariants"; + +export type VscodeCatalogModel = { + id?: string; + name?: string; + root?: string; + owned_by?: string; + capabilities?: Record; + supportsReasoningEffort?: string[]; + supportedReasoningEfforts?: string[]; + supports_reasoning_effort?: string[]; + defaultReasoningEffort?: string; + default_reasoning_effort?: string; +}; + +const EFFORT_SUFFIX_PATTERN = /-(xhigh|high|medium|low|none)$/i; +const DEFAULT_REASONING_EFFORT = "none"; +const KNOWN_REASONING_EFFORTS = new Set(["none", "low", "medium", "high", "xhigh"]); + +export type VscodeModelConfigSchema = { + type: "object"; + properties: { + reasoningEffort: { + type: "string"; + title: string; + description: string; + default: string; + enum: string[]; + enumLabels: string[]; + enumDescriptions: string[]; + }; + }; +}; + +export function getCatalogModelName(model: VscodeCatalogModel) { + return stripVscodeServiceTierVariantModelId(model.id || model.name || model.root || ""); +} + +function normalizeReasoningEffortValue(value: string) { + const normalized = value.trim().toLowerCase().replace(/[_\s-]+/g, ""); + if (normalized === "xhigh") return "xhigh"; + if (KNOWN_REASONING_EFFORTS.has(normalized)) return normalized; + return undefined; +} + +function getNativeReasoningEffortValues(model: VscodeCatalogModel) { + const candidates = [ + model.supportsReasoningEffort, + model.supportedReasoningEfforts, + model.supports_reasoning_effort, + ]; + + for (const candidate of candidates) { + if (!Array.isArray(candidate) || candidate.length === 0) { + continue; + } + + const normalized = Array.from( + new Set(candidate.map(value => typeof value === "string" ? normalizeReasoningEffortValue(value) : undefined).filter(Boolean)) + ) as string[]; + + if (normalized.length > 0) { + return normalized; + } + } + + return undefined; +} + +export function isReasoningCapableModel(model: VscodeCatalogModel) { + return ( + model.capabilities?.reasoning === true || + model.capabilities?.thinking === true || + (getNativeReasoningEffortValues(model)?.length || 0) > 0 + ); +} + +export function getReasoningEffortValues(model: VscodeCatalogModel) { + const nativeReasoningEffortValues = getNativeReasoningEffortValues(model); + if (nativeReasoningEffortValues && nativeReasoningEffortValues.length > 0) { + return nativeReasoningEffortValues; + } + + if (!isReasoningCapableModel(model)) return undefined; + + const modelId = getCatalogModelName(model); + const parsed = parseModel(modelId, ""); + const providerId = parsed.provider || model.owned_by || ""; + const providerModelId = parsed.model || model.root || modelId.split("/").pop() || modelId; + const values = ["none", "low", "medium", "high"]; + + if (providerId && providerModelId && supportsXHighEffort(providerId, providerModelId)) { + values.push("xhigh"); + } + + return values; +} + +export function formatReasoningEffortLabel(level: string) { + if (level === "xhigh") return "XHigh"; + return level.charAt(0).toUpperCase() + level.slice(1); +} + +function describeReasoningEffort(level: string) { + switch (level) { + case "none": + return "Disables extra reasoning effort."; + case "low": + return "Uses a light amount of reasoning."; + case "medium": + return "Uses a balanced amount of reasoning."; + case "high": + return "Uses an extended amount of reasoning."; + case "xhigh": + return "Uses the maximum available reasoning effort."; + default: + return `Uses ${formatReasoningEffortLabel(level)} reasoning effort.`; + } +} + +export function buildSupportedReasoningEfforts( + supportedValues: string[] +): string[] { + return [...supportedValues]; +} + +export function inferSelectedReasoningEffort( + model: VscodeCatalogModel, + supportedValues?: string[] +) { + const modelId = getCatalogModelName(model); + const match = modelId.match(EFFORT_SUFFIX_PATTERN); + if (!match) return undefined; + + const selected = match[1]?.toLowerCase(); + if (!selected) return undefined; + if (Array.isArray(supportedValues) && supportedValues.length > 0 && !supportedValues.includes(selected)) { + return undefined; + } + + return selected; +} + +export function getReasoningVariantBaseModelId(modelId: string) { + return modelId.replace(EFFORT_SUFFIX_PATTERN, ""); +} + +export function getDefaultReasoningEffort( + model: VscodeCatalogModel, + supportedValues?: string[] +) { + return inferSelectedReasoningEffort(model, supportedValues) || DEFAULT_REASONING_EFFORT; +} + +export function buildReasoningConfigSchema( + supportedValues: string[], + defaultReasoningEffort: string +): VscodeModelConfigSchema { + return { + type: "object", + properties: { + reasoningEffort: { + type: "string", + title: "Reasoning effort", + description: "Controls how much reasoning effort the model uses.", + default: defaultReasoningEffort, + enum: supportedValues, + enumLabels: supportedValues.map(formatReasoningEffortLabel), + enumDescriptions: supportedValues.map(describeReasoningEffort), + }, + }, + }; +} diff --git a/src/app/api/v1/vscode/[token]/responses/route.ts b/src/app/api/v1/vscode/[token]/responses/route.ts new file mode 100644 index 0000000000..131c373961 --- /dev/null +++ b/src/app/api/v1/vscode/[token]/responses/route.ts @@ -0,0 +1,10 @@ +import { POST as basePost, OPTIONS } from "@/app/api/v1/responses/route"; +import { rewriteVscodeServiceTierRequest } from "@/app/api/v1/vscode/[token]/serviceTierVariants"; +import { withPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest"; + +export { OPTIONS }; + +export async function POST(request: Request) { + const authorizedRequest = withPathTokenApiKey(request); + return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest)); +} diff --git a/src/app/api/v1/vscode/[token]/route.ts b/src/app/api/v1/vscode/[token]/route.ts new file mode 100644 index 0000000000..fa31714fd5 --- /dev/null +++ b/src/app/api/v1/vscode/[token]/route.ts @@ -0,0 +1,20 @@ +import { CORS_HEADERS } from "@/shared/utils/cors"; + +type VscodeTokenParams = { + token: string; +}; + +export async function OPTIONS() { + return new Response(null, { headers: CORS_HEADERS }); +} + +export async function GET( + request: Request, + context?: { params: Promise | VscodeTokenParams } +) { + const modelsRoute = await import("@/app/api/v1/vscode/[token]/models/route"); + const requestUrl = new URL(request.url); + requestUrl.pathname = `${requestUrl.pathname.replace(/\/+$/, "")}/models`; + const modelsRequest = new Request(requestUrl, request); + return modelsRoute.GET(modelsRequest, context); +} \ No newline at end of file diff --git a/src/app/api/v1/vscode/[token]/serviceTierVariants.ts b/src/app/api/v1/vscode/[token]/serviceTierVariants.ts new file mode 100644 index 0000000000..8e69f224c3 --- /dev/null +++ b/src/app/api/v1/vscode/[token]/serviceTierVariants.ts @@ -0,0 +1,190 @@ +import { CODEX_FAST_TIER_DEFAULT_SUPPORTED_MODELS } from "@/lib/providers/codexFastTier"; +import { normalizeServiceTierId, type ServiceTierId } from "@/shared/utils/serviceTierLabels"; +import { resolveFamilyFirstPublishedModelId } from "@/app/api/v1/vscode/[token]/familyFirstModelIds"; + +const SERVICE_TIER_VARIANT_PATTERN = /__tier_(priority|flex)$/i; +const SUPPORTED_VSCODE_SERVICE_TIERS: readonly ServiceTierId[] = ["priority", "flex"]; + +export type VscodeServiceTierModelLike = { + id?: string; + name?: string; + root?: string; + owned_by?: string; +}; + +export function parseVscodeServiceTierVariantModelId(modelId: string | null | undefined): { + baseModelId: string; + serviceTier?: ServiceTierId; +} { + const rawModelId = typeof modelId === "string" ? modelId.trim() : ""; + if (!rawModelId) { + return { baseModelId: "" }; + } + + const match = rawModelId.match(SERVICE_TIER_VARIANT_PATTERN); + if (!match) { + return { baseModelId: rawModelId }; + } + + const baseModelId = rawModelId.replace(SERVICE_TIER_VARIANT_PATTERN, ""); + const serviceTier = normalizeServiceTierId(match[1]); + return serviceTier === "standard" ? { baseModelId } : { baseModelId, serviceTier }; +} + +export function stripVscodeServiceTierVariantModelId(modelId: string | null | undefined): string { + return parseVscodeServiceTierVariantModelId(modelId).baseModelId; +} + +export function isVscodeServiceTierVariantModelId(modelId: string | null | undefined): boolean { + return Boolean(parseVscodeServiceTierVariantModelId(modelId).serviceTier); +} + +export function getVscodeServiceTierVariantModelId( + baseModelId: string, + serviceTier: ServiceTierId +): string { + if (serviceTier === "standard") { + return baseModelId; + } + return `${baseModelId}__tier_${serviceTier}`; +} + +function getRawModelId(model: VscodeServiceTierModelLike): string { + return (model.id || model.name || model.root || "").trim(); +} + +function getModelProvider(model: VscodeServiceTierModelLike, baseModelId: string): string { + const owner = typeof model.owned_by === "string" ? model.owned_by.trim().toLowerCase() : ""; + if (owner) { + return owner; + } + const prefix = baseModelId.split("/")[0]?.trim().toLowerCase() || ""; + return prefix; +} + +function supportsCodexServiceTierModel(baseModelId: string): boolean { + const normalizedModel = (baseModelId.split("/").pop() || baseModelId).trim().toLowerCase(); + if (!normalizedModel) { + return false; + } + + return CODEX_FAST_TIER_DEFAULT_SUPPORTED_MODELS.some((candidate) => { + const normalizedCandidate = candidate.trim().toLowerCase(); + return normalizedModel === normalizedCandidate || normalizedModel.startsWith(normalizedCandidate); + }); +} + +export function supportsVscodeServiceTierVariants(model: VscodeServiceTierModelLike): boolean { + const rawModelId = getRawModelId(model); + if (!rawModelId) { + return false; + } + + const baseModelId = stripVscodeServiceTierVariantModelId(rawModelId); + const provider = getModelProvider(model, baseModelId); + if (provider !== "codex" && provider !== "cx") { + return false; + } + + return supportsCodexServiceTierModel(baseModelId); +} + +function cloneModelIdentifiers( + model: T, + modelId: string +): T { + return { + ...model, + ...(model.id ? { id: modelId } : {}), + ...(model.name ? { name: modelId } : {}), + ...(model.root ? { root: modelId } : {}), + }; +} + +export function expandVscodeServiceTierModels(models: T[]): T[] { + const expanded: T[] = []; + + for (const model of models) { + const rawModelId = getRawModelId(model); + if (!rawModelId) { + expanded.push(model); + continue; + } + + const baseModelId = stripVscodeServiceTierVariantModelId(rawModelId); + const baseModel = rawModelId === baseModelId ? model : cloneModelIdentifiers(model, baseModelId); + expanded.push(baseModel as T); + + if (!supportsVscodeServiceTierVariants(model)) { + continue; + } + + for (const serviceTier of SUPPORTED_VSCODE_SERVICE_TIERS) { + expanded.push(cloneModelIdentifiers(baseModel as T, getVscodeServiceTierVariantModelId(baseModelId, serviceTier))); + } + } + + return expanded; +} + +export function getVscodeServiceTierVariantSuffix(serviceTier: ServiceTierId | undefined): string { + if (serviceTier === "priority") { + return "Fast"; + } + if (serviceTier === "flex") { + return "Flex"; + } + return "Default"; +} + +export function resolveVscodeServiceTierRequest(body: Record): Record { + const rawModelId = typeof body.model === "string" ? body.model.trim() : ""; + if (!rawModelId) { + return body; + } + + const resolvedModelId = resolveFamilyFirstPublishedModelId(rawModelId); + + const { baseModelId, serviceTier } = parseVscodeServiceTierVariantModelId(resolvedModelId); + if (!serviceTier) { + if (resolvedModelId === rawModelId) { + return body; + } + + return { + ...body, + model: resolvedModelId, + }; + } + + return { + ...body, + model: baseModelId, + ...(body.service_tier === undefined ? { service_tier: serviceTier } : {}), + }; +} + +export async function rewriteVscodeServiceTierRequest(request: Request): Promise { + if (request.method !== "POST") { + return request; + } + + const body = await request.clone().json().catch(() => null); + if (!body || typeof body !== "object" || Array.isArray(body)) { + return request; + } + + const rewrittenBody = resolveVscodeServiceTierRequest(body as Record); + if (rewrittenBody === body) { + return request; + } + + const headers = new Headers(request.headers); + headers.delete("content-length"); + + return new Request(request.url, { + method: request.method, + headers, + body: JSON.stringify(rewrittenBody), + }); +} diff --git a/src/app/api/v1/vscode/[token]/tokenizedRequest.ts b/src/app/api/v1/vscode/[token]/tokenizedRequest.ts new file mode 100644 index 0000000000..d390cadcd5 --- /dev/null +++ b/src/app/api/v1/vscode/[token]/tokenizedRequest.ts @@ -0,0 +1,50 @@ +function inferTokenFromVscodePath(request: Request) { + try { + const url = new URL(request.url, "http://localhost"); + const segments = url.pathname + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean); + + const vscodeIndex = segments.indexOf("vscode"); + if (vscodeIndex === -1) return null; + + const candidate = segments[vscodeIndex + 1]; + if (!candidate || candidate === "raw" || candidate === "combos") { + const nestedCandidate = segments[vscodeIndex + 2]; + return nestedCandidate ? decodeURIComponent(nestedCandidate) : null; + } + + return decodeURIComponent(candidate); + } catch { + return null; + } +} + +export function withPathTokenApiKey(request: Request, token?: string) { + const resolvedToken = token || inferTokenFromVscodePath(request); + if (!resolvedToken) return request; + + const headers = new Headers(request.headers); + + if (!headers.has("x-api-key")) { + headers.set("x-api-key", resolvedToken); + } + + if (!headers.has("authorization")) { + headers.set("authorization", `Bearer ${resolvedToken}`); + } + + const method = request.method; + const init: RequestInit & { duplex?: "half" } = { + method, + headers, + }; + + if (method !== "GET" && method !== "HEAD") { + init.body = request.body; + init.duplex = "half"; + } + + return new Request(request.url, init); +} \ No newline at end of file diff --git a/src/app/api/v1/vscode/[token]/v1/chat/completions/route.ts b/src/app/api/v1/vscode/[token]/v1/chat/completions/route.ts new file mode 100644 index 0000000000..b243f2fe8b --- /dev/null +++ b/src/app/api/v1/vscode/[token]/v1/chat/completions/route.ts @@ -0,0 +1,10 @@ +import { POST as basePost, OPTIONS } from "@/app/api/v1/chat/completions/route"; +import { rewriteVscodeServiceTierRequest } from "@/app/api/v1/vscode/[token]/serviceTierVariants"; +import { withPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest"; + +export { OPTIONS }; + +export async function POST(request: Request) { + const authorizedRequest = withPathTokenApiKey(request); + return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest)); +} diff --git a/src/app/api/v1/vscode/[token]/v1/models/route.ts b/src/app/api/v1/vscode/[token]/v1/models/route.ts new file mode 100644 index 0000000000..3a5d6f63d4 --- /dev/null +++ b/src/app/api/v1/vscode/[token]/v1/models/route.ts @@ -0,0 +1 @@ +export { GET, OPTIONS } from "@/app/api/v1/vscode/[token]/models/route"; diff --git a/src/app/api/v1/vscode/combos/[token]/[[...slug]]/route.ts b/src/app/api/v1/vscode/combos/[token]/[[...slug]]/route.ts new file mode 100644 index 0000000000..8e1d2ef107 --- /dev/null +++ b/src/app/api/v1/vscode/combos/[token]/[[...slug]]/route.ts @@ -0,0 +1,421 @@ +/** + * VS Code Combos endpoint with Ollama compatibility + * + * Intercepts both: + * - GET /api/v1/vscode/combos/{token} → returns combo metadata + * - GET /api/v1/vscode/combos/{token}/api/version → returns Ollama-compatible version + * - GET /api/v1/vscode/combos/{token}/api/tags → exposes combo catalog in Ollama format + */ +import { getUnifiedModelsResponse } from "@/app/api/v1/models/catalog"; +import { + buildReasoningConfigSchema, + buildSupportedReasoningEfforts, + getDefaultReasoningEffort, + getReasoningEffortValues, + inferSelectedReasoningEffort, + type VscodeCatalogModel, +} from "@/app/api/v1/vscode/[token]/reasoningMetadata"; +import { + getVscodeRawModelDisplayName, +} from "@/app/api/v1/vscode/[token]/models/route"; +import { withPathTokenApiKey } from "@/app/api/v1/vscode/[token]/tokenizedRequest"; +import { getCanonicalModelMetadata } from "@/lib/modelMetadataRegistry"; +import { CORS_HEADERS } from "@/shared/utils/cors"; + +const OLLAMA_COMPAT_VERSION = "0.6.4"; + +type ComboCatalogEntry = { + id?: string; + name?: string; + root?: string; + owned_by?: string; + parent?: string | null; + type?: string; + api_format?: string; + context_length?: number; + max_input_tokens?: number; + max_output_tokens?: number; + input_modalities?: string[]; + output_modalities?: string[]; + supported_endpoints?: string[]; + capabilities?: Record; + [ key: string ]: unknown; +}; + +function isComboCatalogEntry(model: ComboCatalogEntry) { + return typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo"; +} + +async function buildComboCatalog(request: Request) { + const response = await getUnifiedModelsResponse(request, { + "Content-Type": "application/json", + ...CORS_HEADERS, + }); + const body = (await response.json()) as { data?: ComboCatalogEntry[]; [key: string]: unknown }; + + if (!response.ok) { + return { + status: response.status, + headers: { ...CORS_HEADERS }, + body, + data: [] as ComboCatalogEntry[], + }; + } + + const data = Array.isArray(body.data) ? body.data.filter(isComboCatalogEntry) : []; + return { + status: response.status, + headers: { ...CORS_HEADERS }, + body, + data, + }; +} + + +function getComboFamily(model: ComboCatalogEntry, canonicalFamily?: string | null) { + const rawModelId = (model.id || model.name || model.root || "").trim(); + if (rawModelId) return rawModelId; + if (canonicalFamily && canonicalFamily.trim().length > 0) return canonicalFamily.trim(); + return "combo"; +} + +function toOllamaTagCombo(combo: ComboCatalogEntry) { + const actualModelId = (combo.id || combo.name || combo.root || "unknown").trim(); + const canonicalMetadata = getCanonicalModelMetadata({ + provider: combo.owned_by || null, + model: combo.root || combo.id || combo.name || null, + }); + const family = getComboFamily(combo, canonicalMetadata?.metadata.family || null); + const contextLength = typeof combo.context_length === "number" ? combo.context_length : 0; + const reasoningEffortValues = getReasoningEffortValues(combo as VscodeCatalogModel); + const selectedReasoningEffort = reasoningEffortValues + ? inferSelectedReasoningEffort(combo as VscodeCatalogModel, reasoningEffortValues) || "none" + : undefined; + const defaultReasoningEffort = reasoningEffortValues + ? getDefaultReasoningEffort(combo as VscodeCatalogModel, reasoningEffortValues) + : undefined; + const supportedReasoningEfforts = + reasoningEffortValues && reasoningEffortValues.length > 0 + ? buildSupportedReasoningEfforts(reasoningEffortValues) + : undefined; + const configSchema = + reasoningEffortValues && defaultReasoningEffort + ? buildReasoningConfigSchema(reasoningEffortValues, defaultReasoningEffort) + : undefined; + + return { + name: actualModelId, + model: actualModelId, + modified_at: "2026-01-01T00:00:00Z", + size: 0, + digest: `omniroute:combo:${actualModelId}`, + ...(reasoningEffortValues + ? { + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + } + : {}), + details: { + format: "omniroute-combo", + family, + parameter_size: contextLength > 0 ? `${contextLength} ctx` : "unknown", + quantization_level: "dynamic", + ...(reasoningEffortValues + ? { + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + } + : {}), + }, + }; +} + + +function buildComboShowPayload(combo: ComboCatalogEntry) { + const actualModelId = (combo.id || combo.name || combo.root || "unknown").trim(); + const displayName = getVscodeRawModelDisplayName(combo); + const canonicalMetadata = getCanonicalModelMetadata({ + provider: combo.owned_by || null, + model: combo.root || combo.id || combo.name || null, + }); + const family = getComboFamily(combo, canonicalMetadata?.metadata.family || null); + const reasoningEffortValues = getReasoningEffortValues(combo as VscodeCatalogModel); + const selectedReasoningEffort = reasoningEffortValues + ? inferSelectedReasoningEffort(combo as VscodeCatalogModel, reasoningEffortValues) || "none" + : undefined; + const defaultReasoningEffort = reasoningEffortValues + ? getDefaultReasoningEffort(combo as VscodeCatalogModel, reasoningEffortValues) + : undefined; + const supportedReasoningEfforts = + reasoningEffortValues && reasoningEffortValues.length > 0 + ? buildSupportedReasoningEfforts(reasoningEffortValues) + : undefined; + const configSchema = + reasoningEffortValues && defaultReasoningEffort + ? buildReasoningConfigSchema(reasoningEffortValues, defaultReasoningEffort) + : undefined; + const modelCapabilities = combo.capabilities ? { ...combo.capabilities } : undefined; + const capabilities = ["completion"]; + if (combo.capabilities?.vision) capabilities.push("vision"); + if (combo.capabilities?.tool_calling) capabilities.push("tools"); + if (combo.capabilities?.reasoning || combo.capabilities?.thinking) capabilities.push("thinking"); + + return { + model: actualModelId, + remote_model: displayName, + ...(reasoningEffortValues + ? { + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + } + : {}), + license: "proprietary", + modelfile: `FROM ${actualModelId}`, + parameters: "", + template: "", + details: { + parent_model: combo.root || actualModelId, + format: "omniroute-combo", + family, + families: [family], + parameter_size: + typeof combo.context_length === "number" ? `${combo.context_length} ctx` : "unknown", + quantization_level: "dynamic", + ...(reasoningEffortValues + ? { + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + } + : {}), + }, + model_info: { + "general.architecture": "combo", + "general.basename": displayName, + ...(typeof combo.context_length === "number" ? { context_length: combo.context_length } : {}), + ...(typeof combo.context_length === "number" ? { "combo.context_length": combo.context_length } : {}), + ...(typeof combo.max_input_tokens === "number" + ? { max_input_tokens: combo.max_input_tokens } + : {}), + ...(typeof combo.max_output_tokens === "number" + ? { max_output_tokens: combo.max_output_tokens } + : {}), + ...(Array.isArray(combo.input_modalities) + ? { input_modalities: combo.input_modalities } + : {}), + ...(Array.isArray(combo.output_modalities) + ? { output_modalities: combo.output_modalities } + : {}), + ...(modelCapabilities ? { capabilities: modelCapabilities } : {}), + ...(reasoningEffortValues + ? { + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + } + : {}), + }, + capabilities, + }; +} + +function enrichComboForVscode(combo: ComboCatalogEntry, request: Request) { + const requestUrl = new URL(request.url); + const tokenBasePath = requestUrl.pathname.replace(/\/?$/, ""); + const tokenBaseUrl = `${requestUrl.origin}${tokenBasePath}`; + const reasoningEffortValues = getReasoningEffortValues(combo as VscodeCatalogModel); + const supportedReasoningEfforts = + reasoningEffortValues && reasoningEffortValues.length > 0 + ? buildSupportedReasoningEfforts(reasoningEffortValues) + : undefined; + const defaultReasoningEffort = reasoningEffortValues + ? getDefaultReasoningEffort(combo as VscodeCatalogModel, reasoningEffortValues) + : undefined; + const configSchema = + reasoningEffortValues && defaultReasoningEffort + ? buildReasoningConfigSchema(reasoningEffortValues, defaultReasoningEffort) + : undefined; + + return { + ...combo, + name: getVscodeRawModelDisplayName(combo), + url: reasoningEffortValues + ? `${tokenBaseUrl}/responses#models.ai.azure.com` + : `${tokenBaseUrl}/chat/completions#models.ai.azure.com`, + toolCalling: combo.capabilities?.tool_calling === true, + vision: combo.capabilities?.vision === true, + maxInputTokens: combo.max_input_tokens || combo.context_length, + maxOutputTokens: combo.max_output_tokens, + family: (combo.id || combo.name || combo.root || "combo").trim() || "combo", + ...(reasoningEffortValues ? { supportsReasoningEffort: reasoningEffortValues } : {}), + ...(supportedReasoningEfforts ? { supportedReasoningEfforts } : {}), + ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + }; +} + +async function readRequestedModelName(request: Request) { + const payload = await request + .clone() + .json() + .catch(() => null); + + if (!payload || typeof payload !== "object") { + return null; + } + + const record = payload as Record; + const candidate = record.name ?? record.model; + return typeof candidate === "string" && candidate.trim().length > 0 ? candidate.trim() : null; +} + +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + ...CORS_HEADERS, + }, + }); +} + +export async function GET( + request: Request, + { + params, + }: { + params: Promise<{ token: string; slug?: string[] }> | { token: string; slug?: string[] }; + } +) { + const resolvedParams = await params; + const slugPath = (resolvedParams.slug || []).join("/"); + const authorizedRequest = withPathTokenApiKey(request, resolvedParams.token); + + // Handle /api/version request (Ollama compatibility check) + if (slugPath === "api/version") { + return Response.json( + { version: OLLAMA_COMPAT_VERSION }, + { headers: { ...CORS_HEADERS } } + ); + } + + // Handle /api/tags request (redirect to models for compatibility) + if (slugPath === "api/tags") { + const catalog = await buildComboCatalog(authorizedRequest); + if (catalog.status < 200 || catalog.status >= 300) { + return Response.json(catalog.body, { + status: catalog.status, + headers: catalog.headers, + }); + } + return Response.json( + { models: catalog.data.map(toOllamaTagCombo) }, + { headers: { ...CORS_HEADERS } } + ); + } + + // Default: return combos metadata + try { + const catalog = await buildComboCatalog(authorizedRequest); + if (catalog.status < 200 || catalog.status >= 300) { + return Response.json(catalog.body, { + status: catalog.status, + headers: catalog.headers, + }); + } + const data = catalog.data.map((combo) => enrichComboForVscode(combo, authorizedRequest)); + + return new Response(JSON.stringify({ object: "list", data }), { + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + ...CORS_HEADERS, + }, + }); + } catch (error) { + return new Response(JSON.stringify({ error: "Failed to fetch combos" }), { + status: 500, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } +} + +export async function POST( + request: Request, + { + params, + }: { + params: Promise<{ token: string; slug?: string[] }> | { token: string; slug?: string[] }; + } +) { + const resolvedParams = await params; + const slugPath = (resolvedParams.slug || []).join("/"); + const authorizedRequest = withPathTokenApiKey(request, resolvedParams.token); + + if (slugPath !== "api/show") { + return new Response(JSON.stringify({ error: "Not found" }), { + status: 404, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); + } + + const requestedName = await readRequestedModelName(request); + if (!requestedName) { + return Response.json( + { error: "Model name is required" }, + { status: 400, headers: { ...CORS_HEADERS } } + ); + } + + const catalog = await buildComboCatalog(authorizedRequest); + if (catalog.status < 200 || catalog.status >= 300) { + return Response.json(catalog.body, { + status: catalog.status, + headers: catalog.headers, + }); + } + + const combo = catalog.data.find( + (entry) => [entry.id, entry.name, entry.root].some((value) => value === requestedName) + ); + + if (!combo) { + return Response.json( + { error: `Model not found: ${requestedName}` }, + { status: 404, headers: { ...CORS_HEADERS } } + ); + } + + return Response.json(buildComboShowPayload(combo), { + headers: { ...CORS_HEADERS }, + }); +} diff --git a/src/app/api/v1/vscode/raw/[token]/api/chat/route.ts b/src/app/api/v1/vscode/raw/[token]/api/chat/route.ts new file mode 100644 index 0000000000..76d167cd68 --- /dev/null +++ b/src/app/api/v1/vscode/raw/[token]/api/chat/route.ts @@ -0,0 +1,5 @@ +/** + * Alias Ollama compatível para chat no namespace raw. + * Apenas reexporta a rota base dessa compatibilidade. + */ +export { POST, OPTIONS } from "@/app/api/v1/api/chat/route"; \ No newline at end of file diff --git a/src/app/api/v1/vscode/raw/[token]/api/show/route.ts b/src/app/api/v1/vscode/raw/[token]/api/show/route.ts new file mode 100644 index 0000000000..5c43ab18a4 --- /dev/null +++ b/src/app/api/v1/vscode/raw/[token]/api/show/route.ts @@ -0,0 +1,327 @@ +import { getUnifiedModelsResponse } from "@/app/api/v1/models/catalog"; +import { getVscodeRawModelDisplayName } from "@/app/api/v1/vscode/[token]/models/route"; +import { getCanonicalModelMetadata } from "@/lib/modelMetadataRegistry"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { expandVscodeRawModels } from "@/app/api/v1/vscode/[token]/models/route"; +import { + buildReasoningConfigSchema, + buildSupportedReasoningEfforts, + getDefaultReasoningEffort, + getReasoningEffortValues, + inferSelectedReasoningEffort, + type VscodeCatalogModel, +} from "@/app/api/v1/vscode/raw/[token]/reasoningMetadata"; +import { parseVscodeServiceTierVariantModelId } from "@/app/api/v1/vscode/raw/[token]/serviceTierVariants"; +import { getFamilyFirstModelCandidates } from "@/app/api/v1/vscode/raw/[token]/familyFirstModelIds"; +import { withPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest"; + +type OpenAiCatalogModel = { + id?: string; + name?: string; + root?: string; + parent?: string | null; + owned_by?: string; + type?: string; + api_format?: string; + context_length?: number; + max_output_tokens?: number; + capabilities?: Record; + input_modalities?: string[]; + output_modalities?: string[]; + supported_endpoints?: string[]; +}; + +function isUsableChatModel(model: OpenAiCatalogModel) { + if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { + return false; + } + if (typeof model.parent === "string" && model.parent.length > 0) return false; + if (typeof model.type === "string" && model.type !== "chat") return false; + + const apiFormat = typeof model.api_format === "string" ? model.api_format : "chat-completions"; + if (apiFormat !== "chat-completions") return false; + + if ( + Array.isArray(model.supported_endpoints) && + model.supported_endpoints.length > 0 && + !model.supported_endpoints.includes("chat") + ) { + return false; + } + + if ( + Array.isArray(model.output_modalities) && + model.output_modalities.length > 0 && + !model.output_modalities.includes("text") + ) { + return false; + } + + return true; +} + +function getCatalogModelId(model: OpenAiCatalogModel) { + return model.id || model.name || model.root || "unknown"; +} + +function normalizeArchitectureKey(value: string) { + const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); + return normalized || "model"; +} + +function normalizeArchitectureSource(value: string) { + const normalized = value.trim().toLowerCase(); + if (normalized === "cx") return "codex"; + if (normalized === "gh") return "github"; + return value; +} + +function getRequestedModelName(payload: unknown): string | null { + if (!payload || typeof payload !== "object") return null; + const record = payload as Record; + const candidate = record.name ?? record.model; + return typeof candidate === "string" && candidate.trim().length > 0 ? candidate.trim() : null; +} + +function getOllamaModelFamily(model: OpenAiCatalogModel, canonicalFamily?: string | null) { + const rawModelId = getCatalogModelId(model).trim(); + const { baseModelId } = parseVscodeServiceTierVariantModelId(rawModelId); + const modelFamily = baseModelId.includes("/") ? baseModelId.split("/").slice(1).join("/") : baseModelId; + + if (modelFamily) { + return modelFamily; + } + + if (canonicalFamily && canonicalFamily.trim().length > 0) { + return canonicalFamily.trim(); + } + + return typeof model.owned_by === "string" && model.owned_by.trim().length > 0 + ? model.owned_by.trim() + : "omniroute"; +} + +function matchesRequestedModel(model: OpenAiCatalogModel, requestedName: string): boolean { + const canonicalMetadata = getCanonicalModelMetadata({ + provider: model.owned_by || null, + model: model.root || model.id || model.name || null, + }); + const family = getOllamaModelFamily(model, canonicalMetadata?.metadata.family || null); + const actualModelId = getCatalogModelId(model); + + return [ + model.id, + model.name, + model.root, + canonicalMetadata?.qualifiedId, + canonicalMetadata?.model, + ...getFamilyFirstModelCandidates(actualModelId, family), + ].some((value) => value === requestedName); +} + +function buildCapabilities(model: OpenAiCatalogModel): string[] { + const capabilities = ["completion"]; + if (model.capabilities?.vision) capabilities.push("vision"); + if (model.capabilities?.tool_calling) capabilities.push("tools"); + if (model.capabilities?.reasoning || model.capabilities?.thinking) capabilities.push("thinking"); + return capabilities; +} + +function buildShowPayload(model: OpenAiCatalogModel, responseModelId?: string) { + const actualModelId = getCatalogModelId(model); + const displayName = getVscodeRawModelDisplayName(model); + const canonicalMetadata = getCanonicalModelMetadata({ + provider: model.owned_by || null, + model: model.root || model.id || model.name || null, + }); + const family = getOllamaModelFamily(model, canonicalMetadata?.metadata.family || null); + const modelId = responseModelId || actualModelId; + const architectureSource = + normalizeArchitectureSource( + canonicalMetadata?.providerAlias || canonicalMetadata?.provider || model.owned_by || family || "model" + ); + const architecture = normalizeArchitectureKey( + architectureSource + ); + const reasoningEffortValues = getReasoningEffortValues(model as VscodeCatalogModel); + const selectedReasoningEffort = reasoningEffortValues + ? inferSelectedReasoningEffort(model as VscodeCatalogModel, reasoningEffortValues) || "none" + : undefined; + const defaultReasoningEffort = reasoningEffortValues + ? getDefaultReasoningEffort(model as VscodeCatalogModel, reasoningEffortValues) + : undefined; + const supportedReasoningEfforts = + reasoningEffortValues && reasoningEffortValues.length > 0 + ? buildSupportedReasoningEfforts(reasoningEffortValues) + : undefined; + const configSchema = + reasoningEffortValues && defaultReasoningEffort + ? buildReasoningConfigSchema(reasoningEffortValues, defaultReasoningEffort) + : undefined; + let modelCapabilities = model.capabilities ? { ...model.capabilities } : undefined; + + if (reasoningEffortValues) { + modelCapabilities = modelCapabilities || {}; + Object.assign(modelCapabilities, { + reasoning: true, + thinking: true, + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + }); + } + + return { + model: modelId, + remote_model: displayName, + ...(reasoningEffortValues + ? { + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + } + : {}), + license: "proprietary", + modelfile: `FROM ${modelId}`, + parameters: "", + template: "", + details: { + parent_model: model.root || actualModelId || "", + format: "openai", + family, + families: [family], + parameter_size: "unknown", + quantization_level: "dynamic", + ...(reasoningEffortValues + ? { + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + } + : {}), + }, + model_info: { + "general.architecture": architecture, + "general.basename": displayName, + ...(typeof model.context_length === "number" ? { context_length: model.context_length } : {}), + ...(typeof model.context_length === "number" + ? { [`${architecture}.context_length`]: model.context_length } + : {}), + ...(typeof model.max_output_tokens === "number" + ? { max_output_tokens: model.max_output_tokens } + : {}), + ...(Array.isArray(model.input_modalities) + ? { input_modalities: model.input_modalities } + : {}), + ...(Array.isArray(model.output_modalities) + ? { output_modalities: model.output_modalities } + : {}), + ...(Array.isArray(model.supported_endpoints) + ? { supported_endpoints: model.supported_endpoints } + : {}), + ...(modelCapabilities ? { capabilities: modelCapabilities } : {}), + ...(reasoningEffortValues + ? { + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + } + : {}), + }, + capabilities: buildCapabilities(model), + }; +} + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function POST( + request: Request, + { params }: { params?: Promise<{ token: string }> | { token: string } } = {} +) { + const resolvedParams = params ? await params : undefined; + const authorizedRequest = withPathTokenApiKey(request, resolvedParams?.token); + const payload = await request + .clone() + .json() + .catch(() => null); + const requestedName = getRequestedModelName(payload); + + if (!requestedName) { + return Response.json( + { + error: "Model name is required", + }, + { + status: 400, + headers: { + ...CORS_HEADERS, + }, + } + ); + } + + const catalogResponse = await getUnifiedModelsResponse(authorizedRequest, { + "Content-Type": "application/json", + ...CORS_HEADERS, + }); + const catalogBody = (await catalogResponse.json()) as { data?: OpenAiCatalogModel[] }; + + if (!catalogResponse.ok) { + return Response.json(catalogBody, { + status: catalogResponse.status, + headers: { + ...CORS_HEADERS, + }, + }); + } + + const expandedModels = Array.isArray(catalogBody.data) + ? expandVscodeRawModels(catalogBody.data.filter(isUsableChatModel)) + : []; + + const model = Array.isArray(expandedModels) + ? expandedModels.find((entry) => matchesRequestedModel(entry, requestedName)) + : undefined; + + if (!model) { + return Response.json( + { + error: `Model not found: ${requestedName}`, + }, + { + status: 404, + headers: { + ...CORS_HEADERS, + }, + } + ); + } + + return Response.json(buildShowPayload(model), { + headers: { + ...CORS_HEADERS, + }, + }); +} diff --git a/src/app/api/v1/vscode/raw/[token]/api/tags/route.ts b/src/app/api/v1/vscode/raw/[token]/api/tags/route.ts new file mode 100644 index 0000000000..830cab45da --- /dev/null +++ b/src/app/api/v1/vscode/raw/[token]/api/tags/route.ts @@ -0,0 +1,213 @@ +import { getUnifiedModelsResponse } from "@/app/api/v1/models/catalog"; +import { getProviderConnections } from "@/lib/db/providers"; +import { getCanonicalModelMetadata } from "@/lib/modelMetadataRegistry"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { expandVscodeRawModels } from "@/app/api/v1/vscode/[token]/models/route"; +import { + buildReasoningConfigSchema, + buildSupportedReasoningEfforts, + getDefaultReasoningEffort, + getReasoningVariantBaseModelId, + getReasoningEffortValues, + inferSelectedReasoningEffort, + type VscodeCatalogModel, +} from "@/app/api/v1/vscode/raw/[token]/reasoningMetadata"; +import { parseVscodeServiceTierVariantModelId } from "@/app/api/v1/vscode/raw/[token]/serviceTierVariants"; +import { withPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest"; + +type OpenAiCatalogModel = { + id?: string; + name?: string; + root?: string; + parent?: string | null; + owned_by?: string; + type?: string; + api_format?: string; + context_length?: number; + output_modalities?: string[]; + supported_endpoints?: string[]; +}; + +function getModelName(model: OpenAiCatalogModel) { + return model.id || model.name || model.root || ""; +} + +function isCodexOwnedModel(model: OpenAiCatalogModel) { + const owner = typeof model.owned_by === "string" ? model.owned_by.trim().toLowerCase() : ""; + const modelName = getModelName(model).toLowerCase(); + + return owner === "codex" || modelName.startsWith("cx/") || modelName.startsWith("codex/"); +} + +async function selectPreferredModels(models: OpenAiCatalogModel[]) { + const activeConnections = (await getProviderConnections({ isActive: true })) as Array<{ + provider?: string; + }>; + const activeProviders = new Set( + activeConnections + .map((connection) => + typeof connection.provider === "string" ? connection.provider.trim().toLowerCase() : "" + ) + .filter(Boolean) + ); + + const preferCodexOnly = + activeProviders.size > 0 && Array.from(activeProviders).every((provider) => provider === "codex"); + if (!preferCodexOnly) return models; + + const codexModels = models.filter(isCodexOwnedModel); + return codexModels.length > 0 ? codexModels : models; +} + +function isUsableChatModel(model: OpenAiCatalogModel) { + if (typeof model.owned_by === "string" && model.owned_by.trim().toLowerCase() === "combo") { + return false; + } + if (typeof model.parent === "string" && model.parent.length > 0) return false; + if (typeof model.type === "string" && model.type !== "chat") return false; + + const apiFormat = typeof model.api_format === "string" ? model.api_format : "chat-completions"; + if (apiFormat !== "chat-completions") return false; + + if ( + Array.isArray(model.supported_endpoints) && + model.supported_endpoints.length > 0 && + !model.supported_endpoints.includes("chat") + ) { + return false; + } + + if ( + Array.isArray(model.output_modalities) && + model.output_modalities.length > 0 && + !model.output_modalities.includes("text") + ) { + return false; + } + + return true; +} + +function getOllamaModelFamily(model: OpenAiCatalogModel, canonicalFamily?: string | null) { + const rawModelId = getModelName(model).trim(); + const tierParsedModel = parseVscodeServiceTierVariantModelId(rawModelId); + const baseModelId = getReasoningVariantBaseModelId(tierParsedModel.baseModelId); + const modelFamily = baseModelId.includes("/") ? baseModelId.split("/").slice(1).join("/") : baseModelId; + + if (modelFamily) { + return modelFamily; + } + + if (canonicalFamily && canonicalFamily.trim().length > 0) { + return canonicalFamily.trim(); + } + + return typeof model.owned_by === "string" && model.owned_by.trim().length > 0 + ? model.owned_by.trim() + : "omniroute"; +} + +function toOllamaTagModel(model: OpenAiCatalogModel) { + const actualModelId = model.id || model.root || "unknown"; + const canonicalMetadata = getCanonicalModelMetadata({ + provider: model.owned_by || null, + model: model.root || model.id || model.name || null, + }); + const family = getOllamaModelFamily(model, canonicalMetadata?.metadata.family || null); + const modelId = actualModelId; + const contextLength = typeof model.context_length === "number" ? model.context_length : 0; + const reasoningEffortValues = getReasoningEffortValues(model as VscodeCatalogModel); + const selectedReasoningEffort = reasoningEffortValues + ? inferSelectedReasoningEffort(model as VscodeCatalogModel, reasoningEffortValues) || "none" + : undefined; + const defaultReasoningEffort = reasoningEffortValues + ? getDefaultReasoningEffort(model as VscodeCatalogModel, reasoningEffortValues) + : undefined; + const supportedReasoningEfforts = + reasoningEffortValues && reasoningEffortValues.length > 0 + ? buildSupportedReasoningEfforts(reasoningEffortValues) + : undefined; + const configSchema = + reasoningEffortValues && defaultReasoningEffort + ? buildReasoningConfigSchema(reasoningEffortValues, defaultReasoningEffort) + : undefined; + + return { + name: modelId, + model: modelId, + modified_at: "2026-01-01T00:00:00Z", + size: 0, + digest: `omniroute:${modelId}`, + ...(reasoningEffortValues + ? { + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + } + : {}), + details: { + format: "openai", + family, + parameter_size: contextLength > 0 ? `${contextLength} ctx` : "unknown", + quantization_level: "dynamic", + ...(reasoningEffortValues + ? { + supports_reasoning_effort: reasoningEffortValues, + supportsReasoningEffort: reasoningEffortValues, + supportedReasoningEfforts, + defaultReasoningEffort, + selected_reasoning_effort: selectedReasoningEffort, + selectedReasoningEffort: selectedReasoningEffort, + ...(configSchema ? { configurationSchema: configSchema } : {}), + ...(configSchema ? { configSchema } : {}), + } + : {}), + }, + }; +} + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function GET( + request: Request, + { params }: { params?: Promise<{ token: string }> | { token: string } } = {} +) { + const resolvedParams = params ? await params : undefined; + const authorizedRequest = withPathTokenApiKey(request, resolvedParams?.token); + const response = await getUnifiedModelsResponse(authorizedRequest, { + "Content-Type": "application/json", + ...CORS_HEADERS, + }); + const body = (await response.json()) as { data?: OpenAiCatalogModel[] }; + + if (!response.ok) { + return Response.json(body, { + status: response.status, + headers: { + ...CORS_HEADERS, + }, + }); + } + + const usableModels = Array.isArray(body.data) ? body.data.filter(isUsableChatModel) : []; + const preferredModels = expandVscodeRawModels(await selectPreferredModels(usableModels)); + const models = preferredModels.map(toOllamaTagModel); + + return Response.json( + { + models, + }, + { + headers: { + ...CORS_HEADERS, + }, + } + ); +} diff --git a/src/app/api/v1/vscode/raw/[token]/api/version/route.ts b/src/app/api/v1/vscode/raw/[token]/api/version/route.ts new file mode 100644 index 0000000000..89cf0622d1 --- /dev/null +++ b/src/app/api/v1/vscode/raw/[token]/api/version/route.ts @@ -0,0 +1,20 @@ +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; + +const OLLAMA_COMPAT_VERSION = "0.6.4"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +export async function GET() { + return Response.json( + { + version: OLLAMA_COMPAT_VERSION, + }, + { + headers: { + ...CORS_HEADERS, + }, + } + ); +} \ No newline at end of file diff --git a/src/app/api/v1/vscode/raw/[token]/chat/completions/route.ts b/src/app/api/v1/vscode/raw/[token]/chat/completions/route.ts new file mode 100644 index 0000000000..3a7abb6ad8 --- /dev/null +++ b/src/app/api/v1/vscode/raw/[token]/chat/completions/route.ts @@ -0,0 +1,10 @@ +import { POST as basePost, OPTIONS } from "@/app/api/v1/chat/completions/route"; +import { rewriteVscodeServiceTierRequest } from "@/app/api/v1/vscode/raw/[token]/serviceTierVariants"; +import { withPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest"; + +export { OPTIONS }; + +export async function POST(request: Request) { + const authorizedRequest = withPathTokenApiKey(request); + return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest)); +} diff --git a/src/app/api/v1/vscode/raw/[token]/combos/route.ts b/src/app/api/v1/vscode/raw/[token]/combos/route.ts new file mode 100644 index 0000000000..f422817fd0 --- /dev/null +++ b/src/app/api/v1/vscode/raw/[token]/combos/route.ts @@ -0,0 +1,41 @@ +/** + * VS Code Combos endpoint — re-export base Ollama-compatible routes + * and intercept GET to return combo metadata. + * + * The VS Code extension expects a standard Ollama server at the base URL, + * so we re-export /api/version, /api/tags, etc. from the [token] parent route. + */ +import { getCombos } from "@/lib/db/combos"; +import { projectCombo, type PublicCombo } from "@/app/api/v1/combos/projectCombo"; + +// Re-export Ollama-compatible endpoints from the parent [token] route +// so VS Code can validate the server version and list models normally +export { OPTIONS } from "@/app/api/v1/vscode/raw/[token]/route"; +export async function GET(request: Request) { + // If client requests /api/version or other Ollama endpoints, delegate to parent + const url = new URL(request.url); + if (url.pathname.includes("/api/version") || url.pathname.includes("/api/tags")) { + const { GET: parentGET } = await import("@/app/api/v1/vscode/raw/[token]/route"); + return parentGET(request); + } + + // Default: return combos metadata + try { + const combos = await getCombos(); + const data = (Array.isArray(combos) ? combos : []) + .map((combo) => projectCombo(combo as Record)) + .filter((combo): combo is PublicCombo => combo !== null); + + return new Response(JSON.stringify({ object: "list", data, combos: data }), { + headers: { + "Content-Type": "application/json", + "Cache-Control": "no-store", + }, + }); + } catch (error) { + return new Response(JSON.stringify({ error: "Failed to fetch combos" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +} diff --git a/src/app/api/v1/vscode/raw/[token]/familyFirstModelIds.ts b/src/app/api/v1/vscode/raw/[token]/familyFirstModelIds.ts new file mode 100644 index 0000000000..c5a2484761 --- /dev/null +++ b/src/app/api/v1/vscode/raw/[token]/familyFirstModelIds.ts @@ -0,0 +1,85 @@ +const FAMILY_FIRST_MODEL_PATTERN = /^((?:gpt-[a-z0-9._-]+|claude[a-z0-9._-]*))(?:__provider_([a-z0-9-]+))(?:__tier_(priority|flex))?$/i; +const TIER_SUFFIX_PATTERN = /(__tier_(?:priority|flex))$/i; + +function normalizeFamily(value: string | null | undefined) { + return typeof value === "string" ? value.trim() : ""; +} + +function splitActualModelId(modelId: string) { + const trimmedModelId = modelId.trim(); + const slashIndex = trimmedModelId.indexOf("/"); + if (slashIndex <= 0 || slashIndex === trimmedModelId.length - 1) { + return null; + } + + return { + providerPrefix: trimmedModelId.slice(0, slashIndex), + providerModelId: trimmedModelId.slice(slashIndex + 1), + }; +} + +function extractTierSuffix(modelId: string) { + const match = modelId.match(TIER_SUFFIX_PATTERN); + return match?.[1] || ""; +} + +function stripTierSuffix(modelId: string) { + return modelId.replace(TIER_SUFFIX_PATTERN, ""); +} + +function isFamilyFirstEligibleFamily(family: string) { + const normalized = family.toLowerCase(); + return normalized.startsWith("gpt-") || normalized.startsWith("claude"); +} + +export function getFamilyFirstPublishedModelId(actualModelId: string, family: string | null | undefined) { + const normalizedFamily = normalizeFamily(family); + if (!normalizedFamily || !isFamilyFirstEligibleFamily(normalizedFamily)) { + return actualModelId; + } + + const parts = splitActualModelId(actualModelId); + if (!parts) { + return actualModelId; + } + + const tierSuffix = extractTierSuffix(parts.providerModelId); + const providerModelBase = stripTierSuffix(parts.providerModelId); + if (providerModelBase !== normalizedFamily) { + return actualModelId; + } + + return `${normalizedFamily}__provider_${parts.providerPrefix}${tierSuffix}`; +} + +export function resolveFamilyFirstPublishedModelId(modelId: string | null | undefined) { + const trimmedModelId = typeof modelId === "string" ? modelId.trim() : ""; + if (!trimmedModelId) { + return trimmedModelId; + } + + const match = trimmedModelId.match(FAMILY_FIRST_MODEL_PATTERN); + if (!match) { + return trimmedModelId; + } + + const [, family, providerPrefix, serviceTier] = match; + const tierSuffix = serviceTier ? `__tier_${serviceTier.toLowerCase()}` : ""; + return `${providerPrefix}/${family}${tierSuffix}`; +} + +export function getFamilyFirstModelCandidates(actualModelId: string, family: string | null | undefined) { + const normalizedFamily = normalizeFamily(family); + const candidates = new Set([actualModelId]); + const publishedModelId = getFamilyFirstPublishedModelId(actualModelId, normalizedFamily); + if (publishedModelId !== actualModelId) { + candidates.add(publishedModelId); + } + + if (normalizedFamily && isFamilyFirstEligibleFamily(normalizedFamily)) { + const tierSuffix = extractTierSuffix(actualModelId); + candidates.add(`${normalizedFamily}${tierSuffix}`); + } + + return [...candidates]; +} \ No newline at end of file diff --git a/src/app/api/v1/vscode/raw/[token]/modelPresentation.ts b/src/app/api/v1/vscode/raw/[token]/modelPresentation.ts new file mode 100644 index 0000000000..fda4259dd2 --- /dev/null +++ b/src/app/api/v1/vscode/raw/[token]/modelPresentation.ts @@ -0,0 +1,178 @@ +import { parseModel } from "@omniroute/open-sse/services/model"; +import { + getCanonicalModelMetadata, + type CanonicalModelMetadata, +} from "@/lib/modelMetadataRegistry"; +import { + getVscodeServiceTierVariantSuffix, + parseVscodeServiceTierVariantModelId, + supportsVscodeServiceTierVariants, +} from "@/app/api/v1/vscode/raw/[token]/serviceTierVariants"; +import { getReasoningVariantBaseModelId } from "@/app/api/v1/vscode/raw/[token]/reasoningMetadata"; +import { resolveFamilyFirstPublishedModelId } from "@/app/api/v1/vscode/raw/[token]/familyFirstModelIds"; +type VscodeCatalogModel = { + id?: string; + name?: string; + root?: string; + owned_by?: string; +}; + +const PROVIDER_NAME_OVERRIDES: Record = { + codex: "Codex", + cx: "Codex", + github: "GitHub", + gh: "GitHub", + "gemini-cli": "Gemini", + gemini: "Gemini", +}; + +function tokenize(value: string) { + return value + .toLowerCase() + .split(/[^a-z0-9]+/i) + .map((part) => part.trim()) + .filter((part) => part.length >= 4); +} + +function getProviderPrefix(metadata: CanonicalModelMetadata | null) { + const providerKey = metadata?.providerAlias || metadata?.provider || ""; + if (providerKey && PROVIDER_NAME_OVERRIDES[providerKey]) { + return PROVIDER_NAME_OVERRIDES[providerKey]; + } + + const providerLabel = metadata?.providerLabel?.trim() || null; + if (!providerLabel) { + return null; + } + if (/codex/i.test(providerLabel)) { + return "Codex"; + } + if (/github/i.test(providerLabel)) { + return "GitHub"; + } + if (/gemini/i.test(providerLabel)) { + return "Gemini"; + } + + return providerLabel; +} + +function normalizeDisplayNameBranding(displayName: string) { + return displayName + .replace(/^OpenAI\s+Codex\b/i, "Codex") + .replace(/^GitHub\s+Copilot\b/i, "GitHub") + .trim(); +} + +function stripLeadingProviderPrefix(displayName: string, providerPrefix: string | null) { + if (!providerPrefix) { + return displayName; + } + + const escapedProviderPrefix = providerPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return displayName.replace(new RegExp(`^${escapedProviderPrefix}\\s+`, "i"), "").trim(); +} + +function looksLikeTechnicalModelName(value: string) { + return /\/|__provider_|__tier_|^[a-z0-9-]+\/[a-z0-9._-]+$/i.test(value); +} + +function humanizeModelIdentifier(modelId: string) { + const identifier = (modelId.split("/").pop() || modelId).trim(); + if (!identifier) { + return identifier; + } + + return identifier + .split(/[-_]+/) + .filter(Boolean) + .map(part => { + if (/^gpt$/i.test(part)) return "GPT"; + if (/^[0-9]+(?:\.[0-9]+)*$/.test(part)) return part; + if (/^[a-z][0-9]$/i.test(part)) return part.toUpperCase(); + return part.charAt(0).toUpperCase() + part.slice(1); + }) + .join(" "); +} + +function resolveFriendlyBaseDisplayName( + rawModelId: string, + metadata: CanonicalModelMetadata | null, + fallbackValue: string +) { + const normalizedFallback = normalizeDisplayNameBranding(fallbackValue.trim()); + if (normalizedFallback && !looksLikeTechnicalModelName(normalizedFallback)) { + return normalizedFallback; + } + + const normalizedModelId = resolveFamilyFirstPublishedModelId(rawModelId); + const parsedTierModel = parseVscodeServiceTierVariantModelId(normalizedModelId); + const canonicalBaseModelId = getReasoningVariantBaseModelId(parsedTierModel.baseModelId); + const parsed = parseModel(canonicalBaseModelId, ""); + const providerModelId = + parsed.model || + (canonicalBaseModelId.includes("/") + ? canonicalBaseModelId.split("/").slice(1).join("/") + : canonicalBaseModelId) || + fallbackValue; + + return humanizeModelIdentifier(providerModelId); +} + +function prefixDisplayName(displayName: string, providerPrefix: string | null) { + const normalizedProviderPrefix = providerPrefix?.trim() || null; + const normalizedDisplayName = normalizeDisplayNameBranding(displayName); + + if (!normalizedProviderPrefix) return normalizedDisplayName; + + const providerTokens = tokenize(normalizedProviderPrefix); + if (providerTokens.length === 0) return normalizedDisplayName; + + const displayNameLower = normalizedDisplayName.toLowerCase(); + if (providerTokens.some((token) => displayNameLower.includes(token))) { + return normalizedDisplayName; + } + + return `${normalizedProviderPrefix} ${stripLeadingProviderPrefix(normalizedDisplayName, normalizedProviderPrefix)}`.trim(); +} + +export function resolveVscodeModelMetadata(model: VscodeCatalogModel) { + const rawModelId = model.id || model.root || model.name || ""; + const normalizedModelId = resolveFamilyFirstPublishedModelId(rawModelId); + const parsedTierModel = parseVscodeServiceTierVariantModelId(normalizedModelId); + const canonicalBaseModelId = getReasoningVariantBaseModelId(parsedTierModel.baseModelId); + const parsed = parseModel(canonicalBaseModelId, ""); + const provider = parsed.provider || model.owned_by || undefined; + const providerModel = + parsed.model || + (canonicalBaseModelId.includes("/") + ? canonicalBaseModelId.split("/").slice(1).join("/") + : canonicalBaseModelId) || + model.root || + model.id || + model.name || + undefined; + + return providerModel && provider + ? getCanonicalModelMetadata({ provider, model: providerModel }) + : providerModel + ? getCanonicalModelMetadata({ model: providerModel }) + : null; +} + +export function getVscodeModelDisplayName(model: VscodeCatalogModel) { + const rawModelId = model.id || model.root || model.name || ""; + const { serviceTier } = parseVscodeServiceTierVariantModelId(rawModelId); + const metadata = resolveVscodeModelMetadata(model); + const displayName = metadata?.displayName || model.name || model.id || model.root || "unknown"; + const prefixedDisplayName = prefixDisplayName(displayName, getProviderPrefix(metadata)); + const shouldShowTierSuffix = Boolean(serviceTier) || supportsVscodeServiceTierVariants(model); + return shouldShowTierSuffix + ? `${prefixedDisplayName} (${getVscodeServiceTierVariantSuffix(serviceTier)})` + : prefixedDisplayName; +} + +export function getVscodeModelGroupingKey(model: VscodeCatalogModel) { + const metadata = resolveVscodeModelMetadata(model); + return metadata?.qualifiedId || metadata?.model || model.id || model.name || model.root || ""; +} diff --git a/src/app/api/v1/vscode/raw/[token]/models/route.ts b/src/app/api/v1/vscode/raw/[token]/models/route.ts new file mode 100644 index 0000000000..74c588514f --- /dev/null +++ b/src/app/api/v1/vscode/raw/[token]/models/route.ts @@ -0,0 +1,43 @@ +import { + enrichModelForVscode, + expandVscodeRawModels, + getVscodeModelsCatalogResponse, +} from "@/app/api/v1/vscode/[token]/models/route"; +import { withPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest"; + +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*", + }, + }); +} + +export async function GET( + request: Request, + { params }: { params?: Promise<{ token: string }> | { token: string } } = {} +) { + const resolvedParams = params ? await params : undefined; + const authorizedRequest = withPathTokenApiKey(request, resolvedParams?.token); + const catalog = await getVscodeModelsCatalogResponse(authorizedRequest); + if (catalog.status < 200 || catalog.status >= 300 || !Array.isArray(catalog.body.data)) { + return Response.json(catalog.body, { + status: catalog.status, + headers: catalog.headers, + }); + } + + return Response.json( + { + ...catalog.body, + data: expandVscodeRawModels(catalog.body.data).map((model) => + enrichModelForVscode(model, authorizedRequest, { preserveNativeId: true }) + ), + }, + { + status: catalog.status, + headers: catalog.headers, + } + ); +} diff --git a/src/app/api/v1/vscode/raw/[token]/reasoningMetadata.ts b/src/app/api/v1/vscode/raw/[token]/reasoningMetadata.ts new file mode 100644 index 0000000000..431bc25a4c --- /dev/null +++ b/src/app/api/v1/vscode/raw/[token]/reasoningMetadata.ts @@ -0,0 +1,175 @@ +import { parseModel } from "@omniroute/open-sse/services/model"; +import { supportsXHighEffort } from "@omniroute/open-sse/config/providerModels"; +import { stripVscodeServiceTierVariantModelId } from "@/app/api/v1/vscode/raw/[token]/serviceTierVariants"; + +export type VscodeCatalogModel = { + id?: string; + name?: string; + root?: string; + owned_by?: string; + capabilities?: Record; + supportsReasoningEffort?: string[]; + supportedReasoningEfforts?: string[]; + supports_reasoning_effort?: string[]; + defaultReasoningEffort?: string; + default_reasoning_effort?: string; +}; + +const EFFORT_SUFFIX_PATTERN = /-(xhigh|high|medium|low|none)$/i; +const DEFAULT_REASONING_EFFORT = "none"; +const KNOWN_REASONING_EFFORTS = new Set(["none", "low", "medium", "high", "xhigh"]); + +export type VscodeModelConfigSchema = { + type: "object"; + properties: { + reasoningEffort: { + type: "string"; + title: string; + description: string; + default: string; + enum: string[]; + enumLabels: string[]; + enumDescriptions: string[]; + }; + }; +}; + +export function getCatalogModelName(model: VscodeCatalogModel) { + return stripVscodeServiceTierVariantModelId(model.id || model.name || model.root || ""); +} + +function normalizeReasoningEffortValue(value: string) { + const normalized = value.trim().toLowerCase().replace(/[_\s-]+/g, ""); + if (normalized === "xhigh") return "xhigh"; + if (KNOWN_REASONING_EFFORTS.has(normalized)) return normalized; + return undefined; +} + +function getNativeReasoningEffortValues(model: VscodeCatalogModel) { + const candidates = [ + model.supportsReasoningEffort, + model.supportedReasoningEfforts, + model.supports_reasoning_effort, + ]; + + for (const candidate of candidates) { + if (!Array.isArray(candidate) || candidate.length === 0) { + continue; + } + + const normalized = Array.from( + new Set(candidate.map(value => typeof value === "string" ? normalizeReasoningEffortValue(value) : undefined).filter(Boolean)) + ) as string[]; + + if (normalized.length > 0) { + return normalized; + } + } + + return undefined; +} + +export function isReasoningCapableModel(model: VscodeCatalogModel) { + return ( + model.capabilities?.reasoning === true || + model.capabilities?.thinking === true || + (getNativeReasoningEffortValues(model)?.length || 0) > 0 + ); +} + +export function getReasoningEffortValues(model: VscodeCatalogModel) { + const nativeReasoningEffortValues = getNativeReasoningEffortValues(model); + if (nativeReasoningEffortValues && nativeReasoningEffortValues.length > 0) { + return nativeReasoningEffortValues; + } + + if (!isReasoningCapableModel(model)) return undefined; + + const modelId = getCatalogModelName(model); + const parsed = parseModel(modelId, ""); + const providerId = parsed.provider || model.owned_by || ""; + const providerModelId = parsed.model || model.root || modelId.split("/").pop() || modelId; + const values = ["none", "low", "medium", "high"]; + + if (providerId && providerModelId && supportsXHighEffort(providerId, providerModelId)) { + values.push("xhigh"); + } + + return values; +} + +export function formatReasoningEffortLabel(level: string) { + if (level === "xhigh") return "XHigh"; + return level.charAt(0).toUpperCase() + level.slice(1); +} + +function describeReasoningEffort(level: string) { + switch (level) { + case "none": + return "Disables extra reasoning effort."; + case "low": + return "Uses a light amount of reasoning."; + case "medium": + return "Uses a balanced amount of reasoning."; + case "high": + return "Uses an extended amount of reasoning."; + case "xhigh": + return "Uses the maximum available reasoning effort."; + default: + return `Uses ${formatReasoningEffortLabel(level)} reasoning effort.`; + } +} + +export function buildSupportedReasoningEfforts( + supportedValues: string[] +): string[] { + return [...supportedValues]; +} + +export function inferSelectedReasoningEffort( + model: VscodeCatalogModel, + supportedValues?: string[] +) { + const modelId = getCatalogModelName(model); + const match = modelId.match(EFFORT_SUFFIX_PATTERN); + if (!match) return undefined; + + const selected = match[1]?.toLowerCase(); + if (!selected) return undefined; + if (Array.isArray(supportedValues) && supportedValues.length > 0 && !supportedValues.includes(selected)) { + return undefined; + } + + return selected; +} + +export function getReasoningVariantBaseModelId(modelId: string) { + return modelId.replace(EFFORT_SUFFIX_PATTERN, ""); +} + +export function getDefaultReasoningEffort( + model: VscodeCatalogModel, + supportedValues?: string[] +) { + return inferSelectedReasoningEffort(model, supportedValues) || DEFAULT_REASONING_EFFORT; +} + +export function buildReasoningConfigSchema( + supportedValues: string[], + defaultReasoningEffort: string +): VscodeModelConfigSchema { + return { + type: "object", + properties: { + reasoningEffort: { + type: "string", + title: "Reasoning effort", + description: "Controls how much reasoning effort the model uses.", + default: defaultReasoningEffort, + enum: supportedValues, + enumLabels: supportedValues.map(formatReasoningEffortLabel), + enumDescriptions: supportedValues.map(describeReasoningEffort), + }, + }, + }; +} diff --git a/src/app/api/v1/vscode/raw/[token]/responses/route.ts b/src/app/api/v1/vscode/raw/[token]/responses/route.ts new file mode 100644 index 0000000000..f61e4eedab --- /dev/null +++ b/src/app/api/v1/vscode/raw/[token]/responses/route.ts @@ -0,0 +1,10 @@ +import { POST as basePost, OPTIONS } from "@/app/api/v1/responses/route"; +import { rewriteVscodeServiceTierRequest } from "@/app/api/v1/vscode/raw/[token]/serviceTierVariants"; +import { withPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest"; + +export { OPTIONS }; + +export async function POST(request: Request) { + const authorizedRequest = withPathTokenApiKey(request); + return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest)); +} diff --git a/src/app/api/v1/vscode/raw/[token]/route.ts b/src/app/api/v1/vscode/raw/[token]/route.ts new file mode 100644 index 0000000000..b0c61329a6 --- /dev/null +++ b/src/app/api/v1/vscode/raw/[token]/route.ts @@ -0,0 +1,20 @@ +import { CORS_HEADERS } from "@/shared/utils/cors"; + +type VscodeTokenParams = { + token: string; +}; + +export async function OPTIONS() { + return new Response(null, { headers: CORS_HEADERS }); +} + +export async function GET( + request: Request, + context?: { params: Promise | VscodeTokenParams } +) { + const modelsRoute = await import("@/app/api/v1/vscode/raw/[token]/models/route"); + const requestUrl = new URL(request.url); + requestUrl.pathname = `${requestUrl.pathname.replace(/\/+$/, "")}/models`; + const modelsRequest = new Request(requestUrl, request); + return modelsRoute.GET(modelsRequest, context); +} \ No newline at end of file diff --git a/src/app/api/v1/vscode/raw/[token]/serviceTierVariants.ts b/src/app/api/v1/vscode/raw/[token]/serviceTierVariants.ts new file mode 100644 index 0000000000..538354a839 --- /dev/null +++ b/src/app/api/v1/vscode/raw/[token]/serviceTierVariants.ts @@ -0,0 +1,190 @@ +import { CODEX_FAST_TIER_DEFAULT_SUPPORTED_MODELS } from "@/lib/providers/codexFastTier"; +import { normalizeServiceTierId, type ServiceTierId } from "@/shared/utils/serviceTierLabels"; +import { resolveFamilyFirstPublishedModelId } from "@/app/api/v1/vscode/raw/[token]/familyFirstModelIds"; + +const SERVICE_TIER_VARIANT_PATTERN = /__tier_(priority|flex)$/i; +const SUPPORTED_VSCODE_SERVICE_TIERS: readonly ServiceTierId[] = ["priority", "flex"]; + +export type VscodeServiceTierModelLike = { + id?: string; + name?: string; + root?: string; + owned_by?: string; +}; + +export function parseVscodeServiceTierVariantModelId(modelId: string | null | undefined): { + baseModelId: string; + serviceTier?: ServiceTierId; +} { + const rawModelId = typeof modelId === "string" ? modelId.trim() : ""; + if (!rawModelId) { + return { baseModelId: "" }; + } + + const match = rawModelId.match(SERVICE_TIER_VARIANT_PATTERN); + if (!match) { + return { baseModelId: rawModelId }; + } + + const baseModelId = rawModelId.replace(SERVICE_TIER_VARIANT_PATTERN, ""); + const serviceTier = normalizeServiceTierId(match[1]); + return serviceTier === "standard" ? { baseModelId } : { baseModelId, serviceTier }; +} + +export function stripVscodeServiceTierVariantModelId(modelId: string | null | undefined): string { + return parseVscodeServiceTierVariantModelId(modelId).baseModelId; +} + +export function isVscodeServiceTierVariantModelId(modelId: string | null | undefined): boolean { + return Boolean(parseVscodeServiceTierVariantModelId(modelId).serviceTier); +} + +export function getVscodeServiceTierVariantModelId( + baseModelId: string, + serviceTier: ServiceTierId +): string { + if (serviceTier === "standard") { + return baseModelId; + } + return `${baseModelId}__tier_${serviceTier}`; +} + +function getRawModelId(model: VscodeServiceTierModelLike): string { + return (model.id || model.name || model.root || "").trim(); +} + +function getModelProvider(model: VscodeServiceTierModelLike, baseModelId: string): string { + const owner = typeof model.owned_by === "string" ? model.owned_by.trim().toLowerCase() : ""; + if (owner) { + return owner; + } + const prefix = baseModelId.split("/")[0]?.trim().toLowerCase() || ""; + return prefix; +} + +function supportsCodexServiceTierModel(baseModelId: string): boolean { + const normalizedModel = (baseModelId.split("/").pop() || baseModelId).trim().toLowerCase(); + if (!normalizedModel) { + return false; + } + + return CODEX_FAST_TIER_DEFAULT_SUPPORTED_MODELS.some((candidate) => { + const normalizedCandidate = candidate.trim().toLowerCase(); + return normalizedModel === normalizedCandidate || normalizedModel.startsWith(normalizedCandidate); + }); +} + +export function supportsVscodeServiceTierVariants(model: VscodeServiceTierModelLike): boolean { + const rawModelId = getRawModelId(model); + if (!rawModelId) { + return false; + } + + const baseModelId = stripVscodeServiceTierVariantModelId(rawModelId); + const provider = getModelProvider(model, baseModelId); + if (provider !== "codex" && provider !== "cx") { + return false; + } + + return supportsCodexServiceTierModel(baseModelId); +} + +function cloneModelIdentifiers( + model: T, + modelId: string +): T { + return { + ...model, + ...(model.id ? { id: modelId } : {}), + ...(model.name ? { name: modelId } : {}), + ...(model.root ? { root: modelId } : {}), + }; +} + +export function expandVscodeServiceTierModels(models: T[]): T[] { + const expanded: T[] = []; + + for (const model of models) { + const rawModelId = getRawModelId(model); + if (!rawModelId) { + expanded.push(model); + continue; + } + + const baseModelId = stripVscodeServiceTierVariantModelId(rawModelId); + const baseModel = rawModelId === baseModelId ? model : cloneModelIdentifiers(model, baseModelId); + expanded.push(baseModel as T); + + if (!supportsVscodeServiceTierVariants(model)) { + continue; + } + + for (const serviceTier of SUPPORTED_VSCODE_SERVICE_TIERS) { + expanded.push(cloneModelIdentifiers(baseModel as T, getVscodeServiceTierVariantModelId(baseModelId, serviceTier))); + } + } + + return expanded; +} + +export function getVscodeServiceTierVariantSuffix(serviceTier: ServiceTierId | undefined): string { + if (serviceTier === "priority") { + return "Fast"; + } + if (serviceTier === "flex") { + return "Flex"; + } + return "Default"; +} + +export function resolveVscodeServiceTierRequest(body: Record): Record { + const rawModelId = typeof body.model === "string" ? body.model.trim() : ""; + if (!rawModelId) { + return body; + } + + const resolvedModelId = resolveFamilyFirstPublishedModelId(rawModelId); + + const { baseModelId, serviceTier } = parseVscodeServiceTierVariantModelId(resolvedModelId); + if (!serviceTier) { + if (resolvedModelId === rawModelId) { + return body; + } + + return { + ...body, + model: resolvedModelId, + }; + } + + return { + ...body, + model: baseModelId, + ...(body.service_tier === undefined ? { service_tier: serviceTier } : {}), + }; +} + +export async function rewriteVscodeServiceTierRequest(request: Request): Promise { + if (request.method !== "POST") { + return request; + } + + const body = await request.clone().json().catch(() => null); + if (!body || typeof body !== "object" || Array.isArray(body)) { + return request; + } + + const rewrittenBody = resolveVscodeServiceTierRequest(body as Record); + if (rewrittenBody === body) { + return request; + } + + const headers = new Headers(request.headers); + headers.delete("content-length"); + + return new Request(request.url, { + method: request.method, + headers, + body: JSON.stringify(rewrittenBody), + }); +} diff --git a/src/app/api/v1/vscode/raw/[token]/tokenizedRequest.ts b/src/app/api/v1/vscode/raw/[token]/tokenizedRequest.ts new file mode 100644 index 0000000000..d390cadcd5 --- /dev/null +++ b/src/app/api/v1/vscode/raw/[token]/tokenizedRequest.ts @@ -0,0 +1,50 @@ +function inferTokenFromVscodePath(request: Request) { + try { + const url = new URL(request.url, "http://localhost"); + const segments = url.pathname + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean); + + const vscodeIndex = segments.indexOf("vscode"); + if (vscodeIndex === -1) return null; + + const candidate = segments[vscodeIndex + 1]; + if (!candidate || candidate === "raw" || candidate === "combos") { + const nestedCandidate = segments[vscodeIndex + 2]; + return nestedCandidate ? decodeURIComponent(nestedCandidate) : null; + } + + return decodeURIComponent(candidate); + } catch { + return null; + } +} + +export function withPathTokenApiKey(request: Request, token?: string) { + const resolvedToken = token || inferTokenFromVscodePath(request); + if (!resolvedToken) return request; + + const headers = new Headers(request.headers); + + if (!headers.has("x-api-key")) { + headers.set("x-api-key", resolvedToken); + } + + if (!headers.has("authorization")) { + headers.set("authorization", `Bearer ${resolvedToken}`); + } + + const method = request.method; + const init: RequestInit & { duplex?: "half" } = { + method, + headers, + }; + + if (method !== "GET" && method !== "HEAD") { + init.body = request.body; + init.duplex = "half"; + } + + return new Request(request.url, init); +} \ No newline at end of file diff --git a/src/app/api/v1/vscode/raw/[token]/v1/chat/completions/route.ts b/src/app/api/v1/vscode/raw/[token]/v1/chat/completions/route.ts new file mode 100644 index 0000000000..3a7abb6ad8 --- /dev/null +++ b/src/app/api/v1/vscode/raw/[token]/v1/chat/completions/route.ts @@ -0,0 +1,10 @@ +import { POST as basePost, OPTIONS } from "@/app/api/v1/chat/completions/route"; +import { rewriteVscodeServiceTierRequest } from "@/app/api/v1/vscode/raw/[token]/serviceTierVariants"; +import { withPathTokenApiKey } from "@/app/api/v1/vscode/raw/[token]/tokenizedRequest"; + +export { OPTIONS }; + +export async function POST(request: Request) { + const authorizedRequest = withPathTokenApiKey(request); + return basePost(await rewriteVscodeServiceTierRequest(authorizedRequest)); +} diff --git a/src/app/api/v1/vscode/raw/[token]/v1/models/route.ts b/src/app/api/v1/vscode/raw/[token]/v1/models/route.ts new file mode 100644 index 0000000000..733636c819 --- /dev/null +++ b/src/app/api/v1/vscode/raw/[token]/v1/models/route.ts @@ -0,0 +1 @@ +export { GET, OPTIONS } from "@/app/api/v1/vscode/raw/[token]/models/route"; \ No newline at end of file diff --git a/tests/unit/vscode-token-routes.test.ts b/tests/unit/vscode-token-routes.test.ts new file mode 100644 index 0000000000..cef7abd78d --- /dev/null +++ b/tests/unit/vscode-token-routes.test.ts @@ -0,0 +1,1207 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-vscode-token-routes-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "vscode-token-routes-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const vscodeRootRoute = await import("../../src/app/api/v1/vscode/[token]/route.ts"); +const vscodeModelsRoute = await import("../../src/app/api/v1/vscode/[token]/models/route.ts"); +const vscodeRawRootRoute = await import("../../src/app/api/v1/vscode/raw/[token]/route.ts"); +const vscodeRawModelsRoute = await import("../../src/app/api/v1/vscode/raw/[token]/models/route.ts"); +const vscodeRawVersionRoute = await import("../../src/app/api/v1/vscode/raw/[token]/api/version/route.ts"); +const vscodeRawShowRoute = await import("../../src/app/api/v1/vscode/raw/[token]/api/show/route.ts"); +const vscodeRawTagsRoute = await import("../../src/app/api/v1/vscode/raw/[token]/api/tags/route.ts"); +const vscodeV1ModelsRoute = await import("../../src/app/api/v1/vscode/[token]/v1/models/route.ts"); +const vscodeVersionRoute = await import("../../src/app/api/v1/vscode/[token]/api/version/route.ts"); +const vscodeShowRoute = await import("../../src/app/api/v1/vscode/[token]/api/show/route.ts"); +const vscodeTagsRoute = await import("../../src/app/api/v1/vscode/[token]/api/tags/route.ts"); +const vscodeV1ChatCompletionsRoute = await import( + "../../src/app/api/v1/vscode/[token]/v1/chat/completions/route.ts" +); +const vscodeChatCompletionsRoute = await import( + "../../src/app/api/v1/vscode/[token]/chat/completions/route.ts" +); +const vscodeResponsesRoute = await import("../../src/app/api/v1/vscode/[token]/responses/route.ts"); +const serviceTierVariants = await import("../../src/app/api/v1/vscode/[token]/serviceTierVariants.ts"); +const combosDb = await import("../../src/lib/db/combos.ts"); + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +async function seedConnection(provider: string, overrides: Record = {}) { + return providersDb.createProviderConnection({ + provider, + authType: (overrides.authType as string) || "apikey", + name: (overrides.name as string) || `${provider}-${Math.random().toString(16).slice(2, 8)}`, + apiKey: (overrides.apiKey as string) || "sk-test", + accessToken: overrides.accessToken as string | undefined, + isActive: (overrides.isActive as boolean) ?? true, + testStatus: (overrides.testStatus as string) || "active", + providerSpecificData: (overrides.providerSpecificData as Record) || {}, + }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("vscode tokenized root route mirrors the grouped VS Code catalog without combos", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("openai", { name: "openai-vscode-root" }); + const key = await apiKeysDb.createApiKey("vscode-root", "machine-vscode-root"); + await combosDb.createCombo({ + name: "root-hidden-combo", + strategy: "priority", + models: [], + }); + + const response = await vscodeRootRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/`) + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.data)); + assert.ok(body.data.length > 0); + assert.equal( + body.data.some((entry: any) => entry.id === "root-hidden-combo" || entry.name === "root-hidden-combo"), + false + ); +}); + +test("vscode tokenized root route exposes friendly model names alongside ids", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("codex", { name: "codex-vscode-root-friendly-name" }); + const key = await apiKeysDb.createApiKey("vscode-root-friendly-name", "machine-vscode-root-friendly-name"); + + const response = await vscodeRootRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/`) + ); + const body = (await response.json()) as any; + const model = (body.data || []).find((entry: any) => entry.id === "gpt-5.4__provider_cx"); + + assert.equal(response.status, 200); + assert.ok(model, "missing gpt-5.4__provider_cx in tokenized VS Code root route"); + assert.equal(model.name, "Codex GPT 5.4 (Default)"); +}); + +test("vscode tokenized models route accepts path-scoped API keys", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("openai", { name: "openai-vscode-models" }); + const key = await apiKeysDb.createApiKey("vscode-models", "machine-vscode-models"); + + const response = await vscodeModelsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/models`) + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.data)); + assert.ok(body.data.length > 0); +}); + +test("vscode tokenized combos route exposes configured combos via token alias", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + + await seedConnection("codex", { name: "codex-vscode-combos-root-basic" }); + + const key = await apiKeysDb.createApiKey("vscode-combos", "machine-vscode-combos"); + await combosDb.createCombo({ + name: "test-combo", + strategy: "priority", + models: [{ kind: "model", model: "codex/gpt-5.4-high", providerId: "codex" }], + }); + + const combosRoute = await import("../../src/app/api/v1/vscode/combos/[token]/[[...slug]]/route.ts"); + const response = await combosRoute.GET( + new Request(`http://localhost/api/v1/vscode/combos/${encodeURIComponent(key.key)}`), + { params: { token: key.key, slug: undefined } } + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.object, "list"); + assert.ok(Array.isArray(body.data), "expected data property to be an array"); + assert.ok(body.data.some((combo: any) => combo.name === "test-combo"), "expected test combo in data response"); + assert.equal("combos" in body, false, "did not expect legacy combos property in response"); +}); + +test("vscode combos route responds to Ollama compatibility check (/api/version)", async () => { + const key = await apiKeysDb.createApiKey("vscode-combos-version", "machine-vscode-combos-version"); + + const combosRoute = await import("../../src/app/api/v1/vscode/combos/[token]/[[...slug]]/route.ts"); + const response = await combosRoute.GET( + new Request(`http://localhost/api/v1/vscode/combos/${encodeURIComponent(key.key)}/api/version`), + { params: { token: key.key, slug: ["api", "version"] } } + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.ok(body.version, "expected version property in response"); + assert.match(body.version as string, /0\.6\.\d+/, "expected Ollama-compatible version format"); +}); + +test("vscode combos route exposes combos through Ollama api/tags", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + + await seedConnection("codex", { name: "codex-vscode-combos-tags" }); + + const key = await apiKeysDb.createApiKey("vscode-combos-tags", "machine-vscode-combos-tags"); + await combosDb.createCombo({ + name: "tags-combo", + strategy: "priority", + models: [{ kind: "model", model: "codex/gpt-5.4-high", providerId: "codex" }], + }); + + const combosRoute = await import("../../src/app/api/v1/vscode/combos/[token]/[[...slug]]/route.ts"); + const response = await combosRoute.GET( + new Request(`http://localhost/api/v1/vscode/combos/${encodeURIComponent(key.key)}/api/tags`), + { params: { token: key.key, slug: ["api", "tags"] } } + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.models), "expected models array in api/tags response"); + const combo = body.models.find((entry: any) => entry.name === "tags-combo"); + assert.ok(combo, "expected combo name in api/tags response"); + assert.equal(combo.details.family, "tags-combo"); + assert.deepEqual(combo.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]); +}); + +test("vscode combos route resolves combo names through Ollama api/show", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + + await seedConnection("codex", { name: "codex-vscode-combos-show" }); + + const key = await apiKeysDb.createApiKey("vscode-combos-show", "machine-vscode-combos-show"); + await combosDb.createCombo({ + name: "show-combo", + strategy: "priority", + models: [{ kind: "model", model: "codex/gpt-5.4-high", providerId: "codex" }], + }); + + const combosRoute = await import("../../src/app/api/v1/vscode/combos/[token]/[[...slug]]/route.ts"); + const response = await combosRoute.POST( + new Request(`http://localhost/api/v1/vscode/combos/${encodeURIComponent(key.key)}/api/show`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "show-combo" }), + }), + { params: { token: key.key, slug: ["api", "show"] } } + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.model, "show-combo"); + assert.equal(body.modelfile, "FROM show-combo"); + assert.equal(body.details.family, "show-combo"); + assert.equal(body.model_info.context_length, 200000); + assert.deepEqual(body.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]); + assert.equal(body.model_info.capabilities.reasoning, true); +}); + +test("vscode tokenized combos root route exposes importable combo metadata", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + + await seedConnection("codex", { name: "codex-vscode-combos-root" }); + const key = await apiKeysDb.createApiKey("vscode-combos-root-rich", "machine-vscode-combos-root-rich"); + await combosDb.createCombo({ + name: "balanced-load", + strategy: "reset-aware", + models: [{ kind: "model", model: "codex/gpt-5.4-high", providerId: "codex" }], + }); + + const combosRoute = await import("../../src/app/api/v1/vscode/combos/[token]/[[...slug]]/route.ts"); + const response = await combosRoute.GET( + new Request(`http://localhost/api/v1/vscode/combos/${encodeURIComponent(key.key)}`), + { params: { token: key.key, slug: undefined } } + ); + const body = (await response.json()) as any; + const combo = body.data.find((entry: any) => entry.id === "balanced-load"); + + assert.equal(response.status, 200); + assert.ok(combo, "expected balanced-load in combo root response"); + assert.equal(combo.url.includes("/responses#models.ai.azure.com"), true); + assert.equal(combo.maxInputTokens, 200000); + assert.equal(combo.maxOutputTokens, 131072); + assert.equal(combo.toolCalling, true); + assert.deepEqual(combo.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]); +}); + +test("vscode tokenized models route exposes reasoning effort metadata for importable chat models", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("github", { + authType: "oauth", + apiKey: null, + accessToken: "gh-test-access-token", + name: "github-vscode-models-reasoning", + }); + const key = await apiKeysDb.createApiKey( + "vscode-models-reasoning", + "machine-vscode-models-reasoning" + ); + + const response = await vscodeModelsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/models`) + ); + const body = (await response.json()) as any; + const model = (body.data || []).find((entry: any) => entry.id === "gpt-5.4__provider_gh"); + + assert.equal(response.status, 200); + assert.ok(model, "missing gpt-5.4__provider_gh in tokenized VS Code models route"); + assert.equal(model.family, "gpt-5.4"); + assert.deepEqual(model.supportsReasoningEffort, ["none", "low", "medium", "high"]); + assert.deepEqual(model.supportedReasoningEfforts, ["none", "low", "medium", "high", "xhigh"]); + assert.deepEqual(model.configurationSchema?.properties?.reasoningEffort?.enum, [ + "none", + "low", + "medium", + "high", + "xhigh", + ]); + assert.equal(model.configurationSchema?.properties?.reasoningEffort?.default, "none"); + assert.equal( + model.url, + `http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/responses#models.ai.azure.com` + ); +}); + +test("vscode tokenized models route keeps xhigh for codex models that advertise it", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("codex", { name: "codex-vscode-models-reasoning" }); + const key = await apiKeysDb.createApiKey( + "vscode-models-codex-reasoning", + "machine-vscode-models-codex-reasoning" + ); + + const response = await vscodeModelsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/models`) + ); + const body = (await response.json()) as any; + const model = (body.data || []).find((entry: any) => entry.id === "gpt-5.4__provider_cx"); + const fastModel = (body.data || []).find((entry: any) => entry.id === "gpt-5.4__provider_cx__tier_priority"); + const flexModel = (body.data || []).find((entry: any) => entry.id === "gpt-5.4__provider_cx__tier_flex"); + + assert.equal(response.status, 200); + assert.ok(model, "missing gpt-5.4__provider_cx in tokenized VS Code models route"); + assert.ok(fastModel, "missing gpt-5.4__provider_cx__tier_priority in tokenized VS Code models route"); + assert.ok(flexModel, "missing gpt-5.4__provider_cx__tier_flex in tokenized VS Code models route"); + assert.equal(model.name, "Codex GPT 5.4 (Default)"); + assert.equal(fastModel.name, "Codex GPT 5.4 (Fast)"); + assert.equal(flexModel.name, "Codex GPT 5.4 (Flex)"); + assert.equal(model.toolCalling, true); + assert.equal(model.vision, true); + assert.deepEqual(model.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]); + assert.deepEqual(model.supportedReasoningEfforts, ["none", "low", "medium", "high", "xhigh"]); + assert.equal(model.defaultReasoningEffort, "none"); + assert.deepEqual(model.configSchema?.properties?.reasoningEffort?.enum, [ + "none", + "low", + "medium", + "high", + "xhigh", + ]); + assert.equal(model.configSchema?.properties?.reasoningEffort?.default, "none"); + const importedIds = new Set((body.data || []).map((entry: any) => entry.id)); + assert.ok(!importedIds.has("cx/gpt-5.4")); + assert.ok(!importedIds.has("cx/gpt-5.4__tier_priority")); + assert.ok(!importedIds.has("cx/gpt-5.4__tier_flex")); + assert.ok(!importedIds.has("codex/gpt-5.4")); + assert.ok(!importedIds.has("cx/gpt-5.4-low")); + assert.ok(!importedIds.has("cx/gpt-5.4-medium")); + assert.ok(!importedIds.has("cx/gpt-5.4-high")); + assert.ok(!importedIds.has("cx/gpt-5.4-xhigh")); + assert.ok(!importedIds.has("cx/gpt-5.4-low__tier_priority")); + assert.ok(!importedIds.has("cx/gpt-5.4-medium__tier_priority")); + assert.ok(!importedIds.has("cx/gpt-5.4-xhigh__tier_flex")); + assert.equal( + model.url, + `http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/responses#models.ai.azure.com` + ); +}); + +test("vscode tokenized raw models route exposes provider-native ids without family-first grouping", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("codex", { name: "codex-vscode-raw-models" }); + const key = await apiKeysDb.createApiKey( + "vscode-raw-models-codex", + "machine-vscode-raw-models-codex" + ); + + const response = await vscodeRawModelsRoute.GET( + new Request(`http://localhost/api/v1/vscode/raw/${encodeURIComponent(key.key)}/models`) + ); + const body = (await response.json()) as any; + const importedIds = new Set((body.data || []).map((entry: any) => entry.id)); + const defaultModel = (body.data || []).find((entry: any) => entry.id === "cx/gpt-5.4"); + const fastModel = (body.data || []).find((entry: any) => entry.id === "cx/gpt-5.4__tier_priority"); + const flexModel = (body.data || []).find((entry: any) => entry.id === "cx/gpt-5.4__tier_flex"); + + assert.equal(response.status, 200); + assert.ok(defaultModel, "missing cx/gpt-5.4 in raw VS Code models route"); + assert.ok(fastModel, "missing cx/gpt-5.4__tier_priority in raw VS Code models route"); + assert.ok(flexModel, "missing cx/gpt-5.4__tier_flex in raw VS Code models route"); + assert.equal(importedIds.size, (body.data || []).length, "raw VS Code models route should not duplicate model ids"); + assert.ok(!importedIds.has("gpt-5.4__provider_cx")); + assert.ok(!importedIds.has("gpt-5.4__provider_cx__tier_priority")); + assert.ok(!importedIds.has("gpt-5.4__provider_cx__tier_flex")); + assert.equal(defaultModel.object, "model"); + assert.equal(typeof defaultModel.created, "number"); + assert.equal(defaultModel.owned_by, "codex"); + assert.equal(defaultModel.name, "Codex GPT 5.4"); + assert.equal(typeof defaultModel.context_length, "number"); + assert.equal(typeof defaultModel.max_output_tokens, "number"); + assert.equal(typeof defaultModel.max_input_tokens, "number"); + assert.deepEqual(defaultModel.capabilities, { + vision: true, + tool_calling: true, + reasoning: true, + thinking: true, + }); + assert.equal(defaultModel.url, undefined); + assert.equal(defaultModel.toolCalling, undefined); + assert.equal(defaultModel.vision, undefined); + assert.equal(defaultModel.family, undefined); + assert.equal(defaultModel.supportsReasoningEffort, undefined); + assert.equal(defaultModel.supportedReasoningEfforts, undefined); + assert.equal(defaultModel.defaultReasoningEffort, undefined); + assert.equal(defaultModel.configurationSchema, undefined); + assert.equal(defaultModel.configSchema, undefined); + assert.equal(defaultModel.maxInputTokens, undefined); + + const lowModel = (body.data || []).find((entry: any) => entry.id === "cx/gpt-5.4-low"); + const mediumModel = (body.data || []).find((entry: any) => entry.id === "cx/gpt-5.4-medium"); + const highModel = (body.data || []).find((entry: any) => entry.id === "cx/gpt-5.4-high"); + const lowFastModel = (body.data || []).find( + (entry: any) => entry.id === "cx/gpt-5.4-low__tier_priority" + ); + const mediumFastModel = (body.data || []).find( + (entry: any) => entry.id === "cx/gpt-5.4-medium__tier_priority" + ); + const highFastModel = (body.data || []).find( + (entry: any) => entry.id === "cx/gpt-5.4-high__tier_priority" + ); + + assert.ok(lowModel, "missing cx/gpt-5.4-low in raw VS Code models route"); + assert.ok(mediumModel, "missing cx/gpt-5.4-medium in raw VS Code models route"); + assert.ok(highModel, "missing cx/gpt-5.4-high in raw VS Code models route"); + assert.ok(lowFastModel, "missing cx/gpt-5.4-low__tier_priority in raw VS Code models route"); + assert.ok( + mediumFastModel, + "missing cx/gpt-5.4-medium__tier_priority in raw VS Code models route" + ); + assert.ok(highFastModel, "missing cx/gpt-5.4-high__tier_priority in raw VS Code models route"); + assert.equal(lowModel.name, "Codex GPT 5.4 (Low)"); + assert.equal(lowFastModel.name, "Codex GPT 5.4 (Low) (Fast)"); + assert.equal(mediumFastModel.name, "Codex GPT 5.4 (Medium) (Fast)"); + assert.equal(highFastModel.name, "Codex GPT 5.4 (High) (Fast)"); + assert.equal(defaultModel.url, undefined); + assert.equal(defaultModel.toolCalling, undefined); + assert.equal(defaultModel.vision, undefined); + assert.equal(defaultModel.family, undefined); + assert.equal(defaultModel.supportsReasoningEffort, undefined); + assert.equal(defaultModel.supportedReasoningEfforts, undefined); + assert.equal(defaultModel.defaultReasoningEffort, undefined); + assert.equal(defaultModel.configurationSchema, undefined); + assert.equal(defaultModel.configSchema, undefined); + assert.equal(defaultModel.maxInputTokens, undefined); + assert.equal(typeof defaultModel.max_output_tokens, "number"); + assert.equal(typeof defaultModel.max_input_tokens, "number"); + assert.deepEqual(defaultModel.capabilities, { + vision: true, + tool_calling: true, + reasoning: true, + thinking: true, + }); +}); + +test("vscode tokenized raw root route mirrors the raw models catalog", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("codex", { name: "codex-vscode-raw-root" }); + const key = await apiKeysDb.createApiKey( + "vscode-raw-root-codex", + "machine-vscode-raw-root-codex" + ); + + const response = await vscodeRawRootRoute.GET( + new Request(`http://localhost/api/v1/vscode/raw/${encodeURIComponent(key.key)}`), + { params: { token: key.key } } + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.data)); + assert.ok(body.data.some((entry: any) => entry.id === "cx/gpt-5.4")); + assert.ok(body.data.some((entry: any) => entry.id === "cx/gpt-5.4-low__tier_priority")); +}); + +test("vscode tokenized raw routes do not publish combo entries", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("openai", { name: "openai-vscode-raw-hide-combos" }); + const key = await apiKeysDb.createApiKey( + "vscode-raw-hide-combos", + "machine-vscode-raw-hide-combos" + ); + await combosDb.createCombo({ + name: "raw-hidden-combo", + strategy: "priority", + models: [], + }); + + const response = await vscodeRawRootRoute.GET( + new Request(`http://localhost/api/v1/vscode/raw/${encodeURIComponent(key.key)}`), + { params: { token: key.key } } + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.data)); + assert.equal( + body.data.some( + (entry: any) => entry.id === "raw-hidden-combo" || entry.name === "raw-hidden-combo" + ), + false + ); +}); + +test("vscode tokenized raw tags route does not publish combo entries", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("codex", { name: "codex-vscode-raw-tags-no-combo" }); + const key = await apiKeysDb.createApiKey( + "vscode-raw-tags-no-combo", + "machine-vscode-raw-tags-no-combo" + ); + await combosDb.createCombo({ + name: "raw-tags-hidden-combo", + strategy: "priority", + models: [{ kind: "model", model: "codex/gpt-5.4-high", providerId: "codex" }], + }); + + const response = await vscodeRawTagsRoute.GET( + new Request(`http://localhost/api/v1/vscode/raw/${encodeURIComponent(key.key)}/api/tags`), + { params: { token: key.key } } + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.models)); + assert.equal( + body.models.some((entry: any) => entry.name === "raw-tags-hidden-combo"), + false + ); +}); + +test("vscode tokenized raw show route resolves reasoning and service-tier variants independently", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("codex", { name: "codex-vscode-raw-show-reasoning-tier" }); + const key = await apiKeysDb.createApiKey( + "vscode-raw-show-reasoning-tier", + "machine-vscode-raw-show-reasoning-tier" + ); + + const response = await vscodeRawShowRoute.POST( + new Request(`http://localhost/api/v1/vscode/raw/${encodeURIComponent(key.key)}/api/show`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "cx/gpt-5.4-low__tier_priority" }), + }), + { params: { token: key.key } } + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.model, "cx/gpt-5.4-low__tier_priority"); + assert.equal(body.remote_model, "Codex GPT 5.4 (Low) (Fast)"); + assert.equal(body.selectedReasoningEffort, "low"); + assert.equal(body.selected_reasoning_effort, "low"); + assert.equal(body.details.selectedReasoningEffort, "low"); + assert.equal(body.details.selected_reasoning_effort, "low"); +}); + +test("vscode tokenized raw api/show does not resolve combo names", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("codex", { name: "codex-vscode-raw-show-no-combo" }); + const key = await apiKeysDb.createApiKey( + "vscode-raw-show-no-combo", + "machine-vscode-raw-show-no-combo" + ); + await combosDb.createCombo({ + name: "raw-show-hidden-combo", + strategy: "priority", + models: [{ kind: "model", model: "codex/gpt-5.4-high", providerId: "codex" }], + }); + + const response = await vscodeRawShowRoute.POST( + new Request(`http://localhost/api/v1/vscode/raw/${encodeURIComponent(key.key)}/api/show`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "raw-show-hidden-combo" }), + }), + { params: { token: key.key } } + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 404); + assert.equal(body.error, "Model not found: raw-show-hidden-combo"); +}); + +test("vscode tokenized raw version route returns Ollama-compatible version", async () => { + const response = await vscodeVersionRoute.GET(); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.match(body.version as string, /0\.6\.\d+/); +}); + +test("vscode tokenized models route prefixes the provider without duplicating brand names", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("gemini-cli", { name: "gemini-cli-vscode-models-labels" }); + const key = await apiKeysDb.createApiKey( + "vscode-models-provider-prefix", + "machine-vscode-models-provider-prefix" + ); + + const response = await vscodeModelsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/models`) + ); + const body = (await response.json()) as any; + const model = (body.data || []).find((entry: any) => entry.id === "gemini-cli/gemini-1.5-pro"); + + assert.equal(response.status, 200); + assert.ok(model, "missing gemini-cli/gemini-1.5-pro in tokenized VS Code models route"); + assert.equal(model.name, "Gemini 1.5 Pro"); +}); + +test("vscode tokenized tags route mirrors the Ollama tags payload", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("openai", { name: "openai-vscode-tags" }); + const key = await apiKeysDb.createApiKey("vscode-tags", "machine-vscode-tags"); + + const response = await vscodeTagsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/tags`) + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.models)); + assert.ok(body.models.length > 0); + assert.equal(typeof body.models[0]?.name, "string"); +}); + +test("vscode tokenized tags route exposes reasoning metadata for codex models", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("codex", { name: "codex-vscode-tags-reasoning" }); + const key = await apiKeysDb.createApiKey("vscode-tags-reasoning", "machine-vscode-tags-reasoning"); + + const response = await vscodeTagsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/tags`) + ); + const body = (await response.json()) as any; + const model = (body.models || []).find((entry: any) => entry.name === "gpt-5.4__provider_cx"); + + assert.equal(response.status, 200); + assert.ok(model, "missing gpt-5.4__provider_cx in tokenized VS Code tags route"); + assert.deepEqual(model.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]); + assert.deepEqual(model.supports_reasoning_effort, ["none", "low", "medium", "high", "xhigh"]); + assert.deepEqual(model.supportedReasoningEfforts, ["none", "low", "medium", "high", "xhigh"]); + assert.equal(model.defaultReasoningEffort, "none"); + assert.equal(model.selectedReasoningEffort, "none"); + assert.equal(model.selected_reasoning_effort, "none"); + assert.equal(model.details.family, "gpt-5.4"); + assert.deepEqual(model.configurationSchema?.properties?.reasoningEffort?.enum, [ + "none", + "low", + "medium", + "high", + "xhigh", + ]); + assert.equal(model.configurationSchema?.properties?.reasoningEffort?.default, "none"); + assert.deepEqual(model.details.configurationSchema?.properties?.reasoningEffort?.enum, [ + "none", + "low", + "medium", + "high", + "xhigh", + ]); + assert.deepEqual(model.details.supports_reasoning_effort, ["none", "low", "medium", "high", "xhigh"]); + assert.equal(model.details.selected_reasoning_effort, "none"); + assert.ok( + !(body.models || []).some((entry: any) => entry.name === "cx/gpt-5.4-low"), + "reasoning variant leaked into grouped VS Code tags route" + ); + assert.ok( + !(body.models || []).some((entry: any) => entry.name === "cx/gpt-5.4-low__tier_priority"), + "tier reasoning variant leaked into grouped VS Code tags route" + ); + assert.ok((body.models || []).some((entry: any) => entry.name === "gpt-5.4__provider_cx__tier_priority")); + assert.ok((body.models || []).some((entry: any) => entry.name === "gpt-5.4__provider_cx__tier_flex")); +}); + +test("vscode tokenized tags route only exposes usable canonical chat models", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("openai", { name: "openai-vscode-tags-usable" }); + const key = await apiKeysDb.createApiKey("vscode-tags-usable", "machine-vscode-tags-usable"); + + const tagsResponse = await vscodeTagsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/tags`) + ); + const tagsBody = (await tagsResponse.json()) as any; + + const modelsResponse = await vscodeModelsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/models`) + ); + const modelsBody = (await modelsResponse.json()) as any; + const rawModelsResponse = await vscodeV1ModelsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/v1/models`) + ); + const rawModelsBody = (await rawModelsResponse.json()) as any; + + assert.equal(tagsResponse.status, 200); + assert.equal(modelsResponse.status, 200); + assert.equal(rawModelsResponse.status, 200); + + const catalogById = new Map( + (modelsBody.data || []).map((model: any) => [model.id, model]) + ); + const rawCatalogById = new Map( + (rawModelsBody.data || []).map((model: any) => [model.id, model]) + ); + type CatalogLike = { + parent?: string | null; + type?: string; + api_format?: string; + supported_endpoints?: string[]; + output_modalities?: string[]; + }; + + for (const tagModel of tagsBody.models || []) { + const catalogModel = (catalogById.get(tagModel.name) || rawCatalogById.get(tagModel.name)) as + | CatalogLike + | undefined; + assert.ok(catalogModel, `missing catalog model for tag ${tagModel.name}`); + assert.ok(!catalogModel.parent, `tag ${tagModel.name} should not expose an alias child`); + assert.ok( + !catalogModel.type || catalogModel.type === "chat", + `tag ${tagModel.name} should be chat-capable` + ); + assert.ok( + !catalogModel.api_format || catalogModel.api_format === "chat-completions", + `tag ${tagModel.name} should use chat-completions` + ); + assert.ok( + !Array.isArray(catalogModel.supported_endpoints) || + catalogModel.supported_endpoints.includes("chat"), + `tag ${tagModel.name} should support chat` + ); + assert.ok( + !Array.isArray(catalogModel.output_modalities) || + catalogModel.output_modalities.includes("text"), + `tag ${tagModel.name} should output text` + ); + } + + const unusableCatalogModels = (modelsBody.data || []).filter( + (model: any) => + model.parent || + (typeof model.type === "string" && model.type !== "chat") || + (typeof model.api_format === "string" && model.api_format !== "chat-completions") || + (Array.isArray(model.supported_endpoints) && !model.supported_endpoints.includes("chat")) || + (Array.isArray(model.output_modalities) && !model.output_modalities.includes("text")) + ); + const tagNames = new Set((tagsBody.models || []).map((model: any) => model.name)); + + for (const model of unusableCatalogModels) { + assert.ok(!tagNames.has(model.id), `unusable model leaked into tags: ${model.id}`); + } +}); + +test("vscode tokenized grouped tags route does not publish combo entries", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("codex", { name: "codex-vscode-grouped-tags-no-combo" }); + const key = await apiKeysDb.createApiKey( + "vscode-grouped-tags-no-combo", + "machine-vscode-grouped-tags-no-combo" + ); + await combosDb.createCombo({ + name: "grouped-hidden-combo", + strategy: "priority", + models: [{ kind: "model", model: "codex/gpt-5.4-high", providerId: "codex" }], + }); + + const response = await vscodeTagsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/tags`) + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.ok(Array.isArray(body.models)); + assert.equal( + body.models.some((entry: any) => entry.name === "grouped-hidden-combo"), + false + ); +}); + +test("vscode tokenized tags route prefers canonical codex models when codex is the only active provider", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("codex", { name: "codex-vscode-tags-canonical" }); + const key = await apiKeysDb.createApiKey("vscode-tags-canonical", "machine-vscode-tags-canonical"); + + const response = await vscodeTagsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/tags`) + ); + const body = (await response.json()) as any; + const tagNames = (body.models || []).map((model: any) => model.name); + + assert.equal(response.status, 200); + assert.ok(tagNames.length > 0); + assert.ok( + tagNames.some((name: string) => name === "gpt-5.5__provider_cx"), + `missing family-first codex tag: ${tagNames.join(", ")}` + ); + assert.ok(tagNames.includes("gpt-5.5__provider_cx")); + assert.ok(!tagNames.includes("cx/gpt-5.5")); + assert.ok(!tagNames.includes("cx/gpt-5.5-low")); + assert.ok(!tagNames.includes("cx/gpt-5.5-medium")); + assert.ok(!tagNames.includes("cx/gpt-5.5-high")); + assert.ok(!tagNames.includes("cx/gpt-5.5-xhigh")); + + for (const name of tagNames) { + assert.ok(!name.startsWith("oc/"), `opencode tag leaked into codex-only endpoint: ${name}`); + } +}); + +test("vscode tokenized api/version route exposes Ollama compatibility version", async () => { + const response = await vscodeVersionRoute.GET(); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.version, "0.6.4"); +}); + +test("vscode raw tokenized api/version route exposes Ollama compatibility version", async () => { + const response = await vscodeRawVersionRoute.GET(); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.version, "0.6.4"); +}); + +test("vscode tokenized api/show route resolves a catalog model through the path token", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("openai", { name: "openai-vscode-show" }); + const key = await apiKeysDb.createApiKey("vscode-show", "machine-vscode-show"); + + const modelsResponse = await vscodeV1ModelsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/v1/models`) + ); + const modelsBody = (await modelsResponse.json()) as any; + const modelId = modelsBody.data?.[0]?.id; + + assert.equal(modelsResponse.status, 200); + assert.equal(typeof modelId, "string"); + + const response = await vscodeShowRoute.POST( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/show`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: modelId }), + }) + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.modelfile, `FROM ${modelId}`); + assert.ok(Array.isArray(body.capabilities)); + assert.ok(body.capabilities.includes("completion")); +}); + +test("vscode tokenized tags names stay resolvable by api/show", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("openai", { name: "openai-vscode-tags-show" }); + const key = await apiKeysDb.createApiKey("vscode-tags-show", "machine-vscode-tags-show"); + + const tagsResponse = await vscodeTagsRoute.GET( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/tags`) + ); + const tagsBody = (await tagsResponse.json()) as any; + const tagModelName = tagsBody.models?.[0]?.name; + + assert.equal(tagsResponse.status, 200); + assert.equal(typeof tagModelName, "string"); + + const showResponse = await vscodeShowRoute.POST( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/show`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: tagModelName }), + }) + ); + const showBody = (await showResponse.json()) as any; + + assert.equal(showResponse.status, 200); + assert.equal(showBody.modelfile, `FROM ${tagModelName}`); +}); + +test("vscode tokenized grouped api/show does not resolve combo names", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("codex", { name: "codex-vscode-grouped-show-no-combo" }); + const key = await apiKeysDb.createApiKey( + "vscode-grouped-show-no-combo", + "machine-vscode-grouped-show-no-combo" + ); + await combosDb.createCombo({ + name: "grouped-show-hidden-combo", + strategy: "priority", + models: [{ kind: "model", model: "codex/gpt-5.4-high", providerId: "codex" }], + }); + + const response = await vscodeShowRoute.POST( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/show`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "grouped-show-hidden-combo" }), + }) + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 404); + assert.equal(body.error, "Model not found: grouped-show-hidden-combo"); +}); + +test("vscode raw tokenized tags names stay resolvable by raw api/show", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("openai", { name: "openai-vscode-raw-tags-show" }); + const key = await apiKeysDb.createApiKey("vscode-raw-tags-show", "machine-vscode-raw-tags-show"); + + const tagsResponse = await vscodeRawTagsRoute.GET( + new Request(`http://localhost/api/v1/vscode/raw/${encodeURIComponent(key.key)}/api/tags`), + { params: { token: key.key } } + ); + const tagsBody = (await tagsResponse.json()) as any; + const tagModelName = tagsBody.models?.[0]?.name; + + assert.equal(tagsResponse.status, 200); + assert.equal(typeof tagModelName, "string"); + + const showResponse = await vscodeRawShowRoute.POST( + new Request(`http://localhost/api/v1/vscode/raw/${encodeURIComponent(key.key)}/api/show`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: tagModelName }), + }), + { params: { token: key.key } } + ); + const showBody = (await showResponse.json()) as any; + + assert.equal(showResponse.status, 200); + assert.equal(showBody.modelfile, `FROM ${tagModelName}`); +}); + +test("vscode tokenized api/show route exposes explicit reasoning effort metadata", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("codex", { name: "codex-vscode-show-reasoning" }); + const key = await apiKeysDb.createApiKey("vscode-show-reasoning", "machine-vscode-show-reasoning"); + + const response = await vscodeShowRoute.POST( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/show`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "gpt-5.4__provider_cx" }), + }) + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.model, "gpt-5.4__provider_cx"); + assert.equal(body.remote_model, "Codex GPT 5.4 (Default)"); + assert.equal(body.details.family, "gpt-5.4"); + assert.deepEqual(body.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]); + assert.deepEqual(body.supports_reasoning_effort, ["none", "low", "medium", "high", "xhigh"]); + assert.deepEqual(body.supportedReasoningEfforts, ["none", "low", "medium", "high", "xhigh"]); + assert.equal(body.defaultReasoningEffort, "none"); + assert.equal(body.selectedReasoningEffort, "none"); + assert.equal(body.selected_reasoning_effort, "none"); + assert.deepEqual(body.configurationSchema?.properties?.reasoningEffort?.enum, [ + "none", + "low", + "medium", + "high", + "xhigh", + ]); + assert.equal(body.configurationSchema?.properties?.reasoningEffort?.default, "none"); + assert.equal(body.model_info["general.basename"], "Codex GPT 5.4 (Default)"); + assert.equal(body.model_info["general.architecture"], "codex"); + assert.equal(body.model_info["codex.context_length"], 200000); + assert.deepEqual(body.model_info.supports_reasoning_effort, ["none", "low", "medium", "high", "xhigh"]); + assert.equal(body.model_info.selected_reasoning_effort, "none"); + assert.deepEqual( + body.model_info.capabilities.supports_reasoning_effort, + ["none", "low", "medium", "high", "xhigh"] + ); +}); + +test("vscode tokenized api/show route exposes service tier variants with suffixed display names", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("codex", { name: "codex-vscode-show-tier-priority" }); + const key = await apiKeysDb.createApiKey("vscode-show-tier-priority", "machine-vscode-show-tier-priority"); + + const response = await vscodeShowRoute.POST( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/show`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "gpt-5.4__provider_cx__tier_priority" }), + }) + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.model, "gpt-5.4__provider_cx__tier_priority"); + assert.equal(body.remote_model, "Codex GPT 5.4 (Fast)"); + assert.equal(body.details.family, "gpt-5.4"); +}); + +test("vscode tokenized chat routes rewrite family-first ids back to the codex provider id", async () => { + const payload = serviceTierVariants.resolveVscodeServiceTierRequest({ model: "gpt-5.4__provider_cx__tier_priority" }); + + assert.equal(payload.model, "cx/gpt-5.4"); + assert.equal(payload.service_tier, "priority"); +}); + +test("vscode tokenized /chat/completions route applies the path token and codex tier rewrite", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + const key = await apiKeysDb.createApiKey("vscode-chat-completions-route", "machine-vscode-chat-completions-route"); + + const response = await vscodeChatCompletionsRoute.POST( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "gpt-5.4__provider_cx__tier_priority", + messages: [{ role: "user", content: "hi" }], + max_tokens: 1, + stream: false, + }), + }) + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 400); + assert.equal(body.error?.code, "bad_request"); + assert.equal(body.error?.message, "No credentials for provider: codex"); +}); + +test("vscode tokenized /responses route applies the path token and codex tier rewrite", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + const key = await apiKeysDb.createApiKey("vscode-responses-route", "machine-vscode-responses-route"); + + const response = await vscodeResponsesRoute.POST( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/responses`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: "gpt-5.4__provider_cx__tier_priority", + input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }], + max_output_tokens: 1, + stream: false, + }), + }) + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 400); + assert.equal(body.error?.code, "bad_request"); + assert.equal(body.error?.message, "No credentials for provider: codex"); +}); + +test("vscode tokenized api/show route preserves the selected reasoning effort for codex variants", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("codex", { name: "codex-vscode-show-reasoning-low" }); + const key = await apiKeysDb.createApiKey( + "vscode-show-reasoning-low", + "machine-vscode-show-reasoning-low" + ); + + const response = await vscodeShowRoute.POST( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/show`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "cx/gpt-5.4-low" }), + }) + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.model_info.selected_reasoning_effort, "low"); + assert.equal(body.model_info.capabilities.selected_reasoning_effort, "low"); +}); + +test("vscode tokenized api/show route resolves canonical family aliases", async () => { + await settingsDb.updateSettings({ + requireLogin: true, + password: "hashed-password", + requireAuthForModels: true, + }); + await seedConnection("codex", { name: "codex-vscode-show-family-alias" }); + const key = await apiKeysDb.createApiKey( + "vscode-show-family-alias", + "machine-vscode-show-family-alias" + ); + + const response = await vscodeShowRoute.POST( + new Request(`http://localhost/api/v1/vscode/${encodeURIComponent(key.key)}/api/show`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "gpt-5.4" }), + }) + ); + const body = (await response.json()) as any; + + assert.equal(response.status, 200); + assert.equal(body.model, "gpt-5.4"); + assert.equal(body.details.family, "gpt-5.4"); +}); + +test("vscode tokenized v1 chat route is exposed under the tokenized base path", async () => { + const response = await vscodeV1ChatCompletionsRoute.OPTIONS(); + + assert.equal(response.status, 204); + assert.match(response.headers.get("Access-Control-Allow-Methods") || "", /POST/); +});