Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 109 additions & 1 deletion src/common/utils/ai/modelCapabilities.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from "bun:test";
import { getModelCapabilities, getSupportedInputMediaTypes } from "./modelCapabilities";
import {
getModelCapabilities,
getSupportedEndpoints,
getSupportedEndpointsResolved,
getSupportedInputMediaTypes,
} from "./modelCapabilities";

describe("getModelCapabilities", () => {
it("returns capabilities for known models", () => {
Expand Down Expand Up @@ -51,6 +56,15 @@ describe("getModelCapabilities", () => {
it("returns null for unknown models", () => {
expect(getModelCapabilities("anthropic:this-model-does-not-exist")).toBeNull();
});

it("inherits bare-model capabilities when provider-scoped entry omits them", () => {
// github_copilot/gpt-4o in models.json lacks supports_pdf_input,
// but bare gpt-4o has it. The merge-across-keys strategy must fill
// in the missing field from the bare model.
const caps = getModelCapabilities("github-copilot:gpt-4o");
expect(caps).not.toBeNull();
expect(caps?.supportsPdfInput).toBe(true);
});
});

describe("getSupportedInputMediaTypes", () => {
Expand All @@ -66,3 +80,97 @@ describe("getSupportedInputMediaTypes", () => {
expect(supported?.has("pdf")).toBe(true);
});
});

describe("getSupportedEndpoints", () => {
it("returns endpoints for a responses-only model", () => {
// gpt-5.4-pro in models-extra has supported_endpoints: ["/v1/responses"]
const endpoints = getSupportedEndpoints("openai:gpt-5.4-pro");
expect(endpoints).toEqual(["/v1/responses"]);
});

it("returns endpoints for a chat-only Copilot model", () => {
// github_copilot/claude-sonnet-4 in models.json has supported_endpoints: ["/v1/chat/completions"]
const endpoints = getSupportedEndpoints("github-copilot:claude-sonnet-4");
expect(endpoints).toEqual(["/v1/chat/completions"]);
});

it("returns both endpoints for a model supporting chat and responses", () => {
// gpt-5.4 in models-extra has supported_endpoints: ["/v1/chat/completions", "/v1/responses"]
const endpoints = getSupportedEndpoints("openai:gpt-5.4");
expect(endpoints).toContain("/v1/chat/completions");
expect(endpoints).toContain("/v1/responses");
});

it("returns endpoints for Copilot model using provider alias lookup", () => {
// github_copilot/gpt-5.2 in models.json has both endpoints
const endpoints = getSupportedEndpoints("github-copilot:gpt-5.2");
expect(endpoints).toContain("/v1/chat/completions");
expect(endpoints).toContain("/v1/responses");
});

it("prefers provider-scoped endpoints over bare model endpoints", () => {
// bare "gpt-5.2" includes /v1/batch, but github_copilot/gpt-5.2 does not.
// The provider-scoped entry should win when queried with a provider prefix.
const endpoints = getSupportedEndpoints("github-copilot:gpt-5.2");
expect(endpoints).not.toContain("/v1/batch");

// Sanity: the bare model does include /v1/batch
const bareEndpoints = getSupportedEndpoints("gpt-5.2");
expect(bareEndpoints).toContain("/v1/batch");
});

it("returns null when model metadata exists but has no endpoint info", () => {
// claude-opus-4-5 in models-extra has no supported_endpoints
const endpoints = getSupportedEndpoints("anthropic:claude-opus-4-5");
expect(endpoints).toBeNull();
});

it("returns null for completely unknown models", () => {
expect(getSupportedEndpoints("unknown:does-not-exist")).toBeNull();
});
});

describe("getSupportedEndpointsResolved", () => {
it("resolves Copilot model with provider-scoped metadata", () => {
// github_copilot/gpt-5.1-codex-max in models.json has supported_endpoints: ["/v1/responses"]
const endpoints = getSupportedEndpointsResolved("github-copilot:gpt-5.1-codex-max", null);
expect(endpoints).toEqual(["/v1/responses"]);
});

it("prefers provider-scoped endpoints over bare model in resolved path", () => {
// github_copilot/gpt-5.2 restricts to chat+responses (no /v1/batch),
// while bare gpt-5.2 includes /v1/batch. Provider-scoped must win.
const endpoints = getSupportedEndpointsResolved("github-copilot:gpt-5.2", null);
expect(endpoints).toContain("/v1/chat/completions");
expect(endpoints).toContain("/v1/responses");
expect(endpoints).not.toContain("/v1/batch");
});

it("falls back to bare model name when provider-scoped entry is missing", () => {
// github_copilot/gpt-5.4 does NOT exist in models.json, but
// bare "gpt-5.4" in models-extra has supported_endpoints.
const endpoints = getSupportedEndpointsResolved("github-copilot:gpt-5.4", null);
expect(endpoints).toContain("/v1/responses");
});

it("resolves endpoints via config-based mappedToModel alias", () => {
// "custom-copilot-alias" has no provider-scoped or bare-model metadata,
// but the providers config maps it to gpt-5.4 which has known endpoints.
const config = {
"github-copilot": {
models: [{ id: "custom-copilot-alias", mappedToModel: "gpt-5.4" }],
},
};
const endpoints = getSupportedEndpointsResolved("github-copilot:custom-copilot-alias", config);
expect(endpoints).toContain("/v1/responses");
});

it("returns null for unknown model when config has no mapping", () => {
// Without config, the same unknown model returns null.
expect(getSupportedEndpointsResolved("github-copilot:custom-copilot-alias", null)).toBeNull();
});

it("returns null for unknown model without any metadata", () => {
expect(getSupportedEndpointsResolved("github-copilot:totally-fake-model", null)).toBeNull();
});
});
90 changes: 79 additions & 11 deletions src/common/utils/ai/modelCapabilities.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ProvidersConfigMap } from "@/common/orpc/types";
import type { ProvidersConfigWithModels } from "@/common/utils/providers/modelEntries";
import { resolveModelForMetadata } from "@/common/utils/providers/modelEntries";
import modelsData from "../tokens/models.json";
import { modelsExtra } from "../tokens/models-extra";
Expand All @@ -11,6 +11,7 @@ interface RawModelCapabilitiesData {
supports_video_input?: boolean;
max_pdf_size_mb?: number;
litellm_provider?: string;
supported_endpoints?: string[];
[key: string]: unknown;
}

Expand Down Expand Up @@ -41,11 +42,12 @@ function generateLookupKeys(modelString: string): string[] {
const modelName = colonIndex !== -1 ? modelString.slice(colonIndex + 1) : modelString;
const litellmProvider = PROVIDER_KEY_ALIASES[provider] ?? provider;

const keys: string[] = [
modelName, // Direct model name (e.g., "claude-opus-4-5")
];
const keys: string[] = [];

if (provider) {
// Provider-scoped keys first so provider-specific metadata (e.g.
// `github_copilot/gpt-5.2` restricting `/v1/batch`) wins over the
// generic bare-model entry.
keys.push(
Comment on lines 47 to 51

Choose a reason for hiding this comment

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

P1 Badge Keep bare-model capability fallback before provider keys

Reordering generateLookupKeys to check provider-scoped keys first changes getModelCapabilities behavior for every provider-prefixed model, not just endpoint selection: the function now returns as soon as it finds a provider entry, even when that entry omits capability fields that exist on the bare model. For example, github_copilot/gpt-4o and github_copilot/gpt-4.1 in models.json lack supports_pdf_input, while bare gpt-4o/gpt-4.1 set it to true; with this ordering, getSupportedInputMediaTypes("github-copilot:gpt-4o") will drop "pdf", regressing attachment support for those Copilot models.

Useful? React with 👍 / 👎.

`${litellmProvider}/${modelName}`, // "ollama/gpt-oss:20b"
`${litellmProvider}/${modelName}-cloud` // "ollama/gpt-oss:20b-cloud" (LiteLLM convention)
Expand All @@ -59,6 +61,9 @@ function generateLookupKeys(modelString: string): string[] {
}
}

// Bare model name is the last-resort fallback.
keys.push(modelName);

return keys;
}

Expand Down Expand Up @@ -90,25 +95,34 @@ export function getModelCapabilities(modelString: string): ModelCapabilities | n
const modelsExtraRecord = modelsExtra as unknown as Record<string, RawModelCapabilitiesData>;
const modelsDataRecord = modelsData as unknown as Record<string, RawModelCapabilitiesData>;

// Merge models.json (upstream) + models-extra.ts (local overrides). Extras win.
// This avoids wiping capabilities (e.g. PDF support) when modelsExtra only overrides
// pricing/token limits.
// Merge across ALL matching lookup keys so provider-scoped entries (first
// in lookup order) override specific fields while bare-model entries fill
// in capabilities the provider-scoped entry omits (e.g. github_copilot/gpt-4o
// lacks supports_pdf_input but bare gpt-4o has it).
// Within each key, modelsExtra wins over modelsData (upstream).
let merged: RawModelCapabilitiesData | null = null;
for (const key of lookupKeys) {
const base = modelsDataRecord[key];
const extra = modelsExtraRecord[key];

if (base || extra) {
const merged: RawModelCapabilitiesData = { ...(base ?? {}), ...(extra ?? {}) };
return extractModelCapabilities(merged);
const keyData: RawModelCapabilitiesData = Object.assign({}, base ?? {}, extra ?? {});
if (merged != null) {
// Earlier keys (provider-scoped) take priority; later keys (bare model)
// fill gaps but don't override.
merged = Object.assign({}, keyData, merged);
} else {
merged = keyData;
}
}
}

return null;
return merged ? extractModelCapabilities(merged) : null;
}

export function getModelCapabilitiesResolved(
modelString: string,
providersConfig: ProvidersConfigMap | null
providersConfig: ProvidersConfigWithModels | null
): ModelCapabilities | null {
const metadataModel = resolveModelForMetadata(modelString, providersConfig);
return getModelCapabilities(metadataModel);
Expand All @@ -127,3 +141,57 @@ export function getSupportedInputMediaTypes(
if (caps.supportsVideoInput) result.add("video");
return result;
}

/**
* Resolve supported API endpoints for a model string from static metadata.
*
* Returns the `supported_endpoints` array (e.g. `["/v1/responses"]`) when
* found in models-extra or models.json, or `null` when no metadata exists
* or the metadata lacks endpoint information.
*/
export function getSupportedEndpoints(modelString: string): string[] | null {
const normalized = normalizeToCanonical(modelString);
const lookupKeys = generateLookupKeys(normalized);

const modelsExtraRecord = modelsExtra as unknown as Record<string, RawModelCapabilitiesData>;
const modelsDataRecord = modelsData as unknown as Record<string, RawModelCapabilitiesData>;

for (const key of lookupKeys) {
const base = modelsDataRecord[key];
const extra = modelsExtraRecord[key];

if (base || extra) {
// Extra wins for the same field; merge so we don't lose base-only endpoints.
const merged: RawModelCapabilitiesData = { ...(base ?? {}), ...(extra ?? {}) };
return merged.supported_endpoints ?? null;
}
}

return null;
}

/**
* Like `getSupportedEndpoints`, but first resolves config aliases
* (e.g. `mappedToModel`) so gateway-scoped model IDs inherit metadata
* from the underlying model when the gateway-scoped key has no entry.
*/
export function getSupportedEndpointsResolved(
modelString: string,
providersConfig: ProvidersConfigWithModels | null
): string[] | null {
// Try the raw (possibly gateway-scoped) key first so provider-specific
// endpoint overrides (e.g. `github_copilot/gpt-5.4`) take priority.
const direct = getSupportedEndpoints(modelString);
if (direct != null) {
return direct;
}

// Fall back to the metadata-resolved alias (e.g. mappedToModel) so
// models without a provider-scoped entry inherit from the bare model.
const metadataModel = resolveModelForMetadata(modelString, providersConfig);
if (metadataModel !== modelString) {
return getSupportedEndpoints(metadataModel);
}

return null;
}
20 changes: 15 additions & 5 deletions src/common/utils/providers/modelEntries.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
import type { ProviderModelEntry, ProvidersConfigMap } from "@/common/orpc/types";
import type { ProviderModelEntry } from "@/common/orpc/types";
import { normalizeToCanonical } from "@/common/utils/ai/models";

/**
* Minimal providers-config shape needed for model-entry lookup.
* Both the raw disk config (`ProvidersConfig`) and the API-facing map
* (`ProvidersConfigMap`) satisfy this, so callers don't need to convert.
*/
export type ProvidersConfigWithModels = Record<
string,
{ models?: ProviderModelEntry[] } | undefined
>;

interface ParsedProviderModelId {
provider: string;
modelId: string;
Expand Down Expand Up @@ -37,7 +47,7 @@ function parseProviderModelId(fullModelId: string): ParsedProviderModelId | null
}

function findProviderModelEntry(
providersConfig: ProvidersConfigMap | null,
providersConfig: ProvidersConfigWithModels | null,
provider: string,
modelId: string
): ProviderModelEntry | null {
Expand Down Expand Up @@ -65,7 +75,7 @@ function findProviderModelEntry(
*/
function findProviderModelEntryScoped(
fullModelId: string,
providersConfig: ProvidersConfigMap | null
providersConfig: ProvidersConfigWithModels | null
): ProviderModelEntry | null {
const rawParsed = parseProviderModelId(fullModelId);
if (rawParsed) {
Expand Down Expand Up @@ -94,15 +104,15 @@ function findProviderModelEntryScoped(

export function getModelContextWindowOverride(
fullModelId: string,
providersConfig: ProvidersConfigMap | null
providersConfig: ProvidersConfigWithModels | null
): number | null {
const entry = findProviderModelEntryScoped(fullModelId, providersConfig);
return entry ? getProviderModelEntryContextWindowTokens(entry) : null;
}

export function resolveModelForMetadata(
fullModelId: string,
providersConfig: ProvidersConfigMap | null
providersConfig: ProvidersConfigWithModels | null
): string {
const entry = findProviderModelEntryScoped(fullModelId, providersConfig);
return (entry ? getProviderModelEntryMappedTo(entry) : null) ?? fullModelId;
Expand Down
1 change: 1 addition & 0 deletions src/common/utils/tokens/models-extra.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export const modelsExtra: Record<string, ModelData> = {
supports_vision: true,
supports_reasoning: true,
supports_response_schema: true,
supported_endpoints: ["/v1/chat/completions", "/v1/responses"],
knowledge_cutoff: "2025-08-31",
},

Expand Down
Loading