Make model catalog capability-aware

This commit is contained in:
musistudio 2026-06-30 12:04:26 +08:00
parent 1a8d7cb610
commit 13517cc2a0
4 changed files with 526 additions and 39 deletions

View file

@ -2260,13 +2260,8 @@ function modelCatalogConfigItem(model, priority) {
slug: model,
display_name: model,
description: "CCR gateway model " + model,
default_reasoning_level: "medium",
supported_reasoning_levels: [
{ effort: "low", description: "Low reasoning" },
{ effort: "medium", description: "Medium reasoning" },
{ effort: "high", description: "High reasoning" },
{ effort: "xhigh", description: "Extra high reasoning" }
],
default_reasoning_level: null,
supported_reasoning_levels: [],
shell_type: "shell_command",
visibility: "list",
supported_in_api: true,
@ -2276,21 +2271,21 @@ function modelCatalogConfigItem(model, priority) {
availability_nux: null,
upgrade: null,
base_instructions: "You are Codex, a coding agent.",
supports_reasoning_summaries: true,
supports_reasoning_summaries: false,
default_reasoning_summary: "none",
support_verbosity: true,
default_verbosity: "low",
apply_patch_tool_type: "freeform",
web_search_tool_type: "text_and_image",
apply_patch_tool_type: null,
web_search_tool_type: "text",
truncation_policy: { mode: "tokens", limit: 10000 },
supports_parallel_tool_calls: true,
supports_image_detail_original: true,
supports_parallel_tool_calls: false,
supports_image_detail_original: false,
context_window: 128000,
max_context_window: 128000,
effective_context_window_percent: 95,
experimental_supported_tools: [],
input_modalities: ["text", "image"],
supports_search_tool: true
input_modalities: ["text"],
supports_search_tool: false
};
}

View file

@ -1,5 +1,11 @@
import type { AppConfig, VirtualModelProfileConfig } from "../shared/app";
import { findModelCatalogEntry, modelCatalogMaxInputTokens } from "../server/gateway/model-catalog";
import { BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME } from "../shared/app";
import type { AppConfig, GatewayProviderConfig, GatewayProviderProtocol, VirtualModelProfileConfig } from "../shared/app";
import {
findModelCatalogEntry,
modelCatalogMaxInputTokens,
readCatalogCapability,
type ModelCatalogEntry
} from "../server/gateway/model-catalog";
const fusionModelProviderName = "Fusion";
const codexDefaultContextWindow = 128_000;
@ -11,11 +17,11 @@ export type CodexModelCatalog = {
export type CodexModelCatalogItem = {
additional_speed_tiers: unknown[];
apply_patch_tool_type: string;
apply_patch_tool_type: string | null;
availability_nux: null;
base_instructions: string;
context_window: number;
default_reasoning_level: string;
default_reasoning_level: string | null;
default_reasoning_summary: string;
description: string;
display_name: string;
@ -42,7 +48,7 @@ export type CodexModelCatalogItem = {
export function buildCodexModelCatalog(config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>, selectedModel?: string): CodexModelCatalog {
return {
models: buildCodexModelCatalogIds(config, selectedModel).map((model, index) => codexModelCatalogItem(model, index))
models: buildCodexModelCatalogIds(config, selectedModel).map((model, index) => codexModelCatalogItem(model, index, config))
};
}
@ -101,21 +107,26 @@ export function codexModelCatalogBase64(config?: Partial<Pick<AppConfig, "Provid
return Buffer.from(JSON.stringify(catalog), "utf8").toString("base64");
}
function codexModelCatalogItem(model: string, priority: number): CodexModelCatalogItem {
const contextWindow = codexModelContextWindow(model);
function codexModelCatalogItem(
model: string,
priority: number,
config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>
): CodexModelCatalogItem {
const profile = codexModelCapabilityProfile(model, config);
const contextWindow = codexModelContextWindow(model, profile.catalogEntry);
return {
additional_speed_tiers: [],
apply_patch_tool_type: "freeform",
apply_patch_tool_type: profile.applyPatchToolType,
availability_nux: null,
base_instructions: "You are Codex, a coding agent.",
context_window: contextWindow,
default_reasoning_level: "medium",
default_reasoning_level: profile.supportsReasoning ? "medium" : null,
default_reasoning_summary: "none",
description: `CCR gateway model ${model}`,
display_name: model,
effective_context_window_percent: codexEffectiveContextWindowPercent,
experimental_supported_tools: [],
input_modalities: ["text", "image"],
input_modalities: profile.inputModalities,
max_context_window: contextWindow,
priority,
service_tiers: [],
@ -123,28 +134,287 @@ function codexModelCatalogItem(model: string, priority: number): CodexModelCatal
slug: model,
support_verbosity: true,
supported_in_api: true,
supported_reasoning_levels: [
{ effort: "low", description: "Low reasoning" },
{ effort: "medium", description: "Medium reasoning" },
{ effort: "high", description: "High reasoning" },
{ effort: "xhigh", description: "Extra high reasoning" }
],
supports_image_detail_original: true,
supports_parallel_tool_calls: true,
supports_reasoning_summaries: true,
supports_search_tool: true,
supported_reasoning_levels: profile.supportedReasoningLevels,
supports_image_detail_original: profile.supportsImageInput,
supports_parallel_tool_calls: profile.supportsParallelToolCalls,
supports_reasoning_summaries: profile.supportsReasoning,
supports_search_tool: profile.supportsSearchTool,
truncation_policy: { mode: "tokens", limit: 10_000 },
upgrade: null,
visibility: "list",
web_search_tool_type: "text_and_image"
web_search_tool_type: profile.supportsSearchTool && profile.supportsImageInput ? "text_and_image" : "text"
};
}
function codexModelContextWindow(model: string): number {
const entry = findModelCatalogEntry(model);
type CodexCapabilityProfile = {
applyPatchToolType: string | null;
catalogEntry?: ModelCatalogEntry;
inputModalities: string[];
supportedReasoningLevels: Array<{ description: string; effort: string }>;
supportsImageInput: boolean;
supportsParallelToolCalls: boolean;
supportsReasoning: boolean;
supportsSearchTool: boolean;
};
function codexModelCapabilityProfile(
model: string,
config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>
): CodexCapabilityProfile {
const selector = parseModelSelector(model);
const provider = selector?.provider ? findConfiguredProvider(config, selector.provider) : undefined;
const catalogEntry = findModelCatalogEntry(model);
const capabilities = catalogEntry?.capabilities ?? {};
const providerProtocol = provider ? codexProviderProtocol(provider) : undefined;
const providerSupportsResponses = provider ? codexProviderSupportsResponses(provider) : false;
const supportsFusionWebSearch = codexVirtualModelSupportsFusionWebSearch(model, config);
const supportsReasoning = readCatalogCapability(capabilities, "reasoning");
const supportsImageInput = catalogEntrySupportsImageInput(catalogEntry);
const supportsParallelToolCalls = readCatalogCapability(capabilities, "parallelFunctionCalling");
const applyPatchToolType = providerSupportsResponses || catalogModelLooksLikeGpt(model, catalogEntry)
? "freeform"
: null;
const supportsSearchTool =
supportsFusionWebSearch ||
(
readCatalogCapability(capabilities, "webSearch") &&
(providerProtocol === "openai_responses" || providerProtocol === "anthropic_messages")
);
return {
applyPatchToolType,
catalogEntry,
inputModalities: supportsImageInput ? ["text", "image"] : ["text"],
supportedReasoningLevels: supportsReasoning ? supportedReasoningLevels(capabilities) : [],
supportsImageInput,
supportsParallelToolCalls,
supportsReasoning,
supportsSearchTool
};
}
function codexModelContextWindow(model: string, entry = findModelCatalogEntry(model)): number {
return modelCatalogMaxInputTokens(entry) || codexDefaultContextWindow;
}
function catalogEntrySupportsImageInput(entry: ModelCatalogEntry | undefined): boolean {
const capabilities = entry?.capabilities ?? {};
const modalities = new Set((entry?.modalities?.input ?? []).map((item) => item.toLowerCase()));
return modalities.has("image") ||
readCatalogCapability(capabilities, "imageInput") ||
readCatalogCapability(capabilities, "vision") ||
readCatalogCapability(capabilities, "multimodal");
}
function supportedReasoningLevels(capabilities: Record<string, unknown>): Array<{ description: string; effort: string }> {
const levels = [
{ effort: "low", description: "Low reasoning" },
{ effort: "medium", description: "Medium reasoning" },
{ effort: "high", description: "High reasoning" }
];
if (readCatalogCapability(capabilities, "xhighReasoningEffort") || readCatalogCapability(capabilities, "maxReasoningEffort")) {
levels.push({ effort: "xhigh", description: "Extra high reasoning" });
}
return levels;
}
function findConfiguredProvider(
config: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">> | undefined,
providerName: string
): GatewayProviderConfig | undefined {
const normalized = providerName.trim().toLowerCase();
if (!normalized) {
return undefined;
}
return (config?.Providers ?? []).find((provider) => provider.name.trim().toLowerCase() === normalized);
}
function codexProviderProtocol(provider: GatewayProviderConfig): GatewayProviderProtocol | undefined {
const capabilityProtocols = uniqueProviderProtocols((provider.capabilities ?? []).map((capability) => normalizeProviderProtocol(capability.type)));
for (const protocol of ["openai_responses", "openai_chat_completions", "anthropic_messages", "gemini_generate_content"] as GatewayProviderProtocol[]) {
if (capabilityProtocols.includes(protocol)) {
return protocol;
}
}
return normalizeProviderProtocol(provider.type) ?? normalizeProviderProtocol(provider.provider) ?? inferProviderProtocol(provider);
}
function codexProviderSupportsResponses(provider: GatewayProviderConfig): boolean {
return uniqueProviderProtocols((provider.capabilities ?? []).map((capability) => normalizeProviderProtocol(capability.type))).includes("openai_responses") ||
normalizeProviderProtocol(provider.type) === "openai_responses" ||
normalizeProviderProtocol(provider.provider) === "openai_responses" ||
providerEndpointLooksLikeResponses(provider);
}
function inferProviderProtocol(provider: GatewayProviderConfig): GatewayProviderProtocol {
const url = (provider.baseUrl || provider.baseurl || provider.api_base_url || "").toLowerCase();
const transformer = JSON.stringify(provider.transformer ?? "").toLowerCase();
if (providerEndpointLooksLikeResponses(provider)) {
return "openai_responses";
}
if (url.includes("generativelanguage.googleapis.com") || transformer.includes("gemini")) {
return "gemini_generate_content";
}
if (url.includes("anthropic") || transformer.includes("anthropic")) {
return "anthropic_messages";
}
return "openai_chat_completions";
}
function providerEndpointLooksLikeResponses(provider: GatewayProviderConfig): boolean {
const url = (provider.baseUrl || provider.baseurl || provider.api_base_url || "").toLowerCase();
return url.endsWith("/responses") || url.includes("/responses?");
}
function catalogModelLooksLikeGpt(model: string, entry: ModelCatalogEntry | undefined): boolean {
return [
model,
entry?.id,
entry?.model
].some((value) => typeof value === "string" && value.toLowerCase().includes("gpt"));
}
function normalizeProviderProtocol(value: unknown): GatewayProviderProtocol | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim().toLowerCase();
if (normalized === "openai" || normalized === "openai_responses") {
return "openai_responses";
}
if (normalized === "openai_chat" || normalized === "openai_chat_completions") {
return "openai_chat_completions";
}
if (normalized === "anthropic" || normalized === "anthropic_messages") {
return "anthropic_messages";
}
if (normalized === "gemini" || normalized === "gemini_generate_content") {
return "gemini_generate_content";
}
return undefined;
}
function uniqueProviderProtocols(values: Array<GatewayProviderProtocol | undefined>): GatewayProviderProtocol[] {
const seen = new Set<GatewayProviderProtocol>();
const output: GatewayProviderProtocol[] = [];
for (const value of values) {
if (!value || seen.has(value)) {
continue;
}
seen.add(value);
output.push(value);
}
return output;
}
function parseModelSelector(model: string): { model: string; provider: string } | undefined {
const normalized = normalizeModelSelector(model);
const slashIndex = normalized.indexOf("/");
if (slashIndex <= 0 || slashIndex >= normalized.length - 1) {
return undefined;
}
return {
provider: normalized.slice(0, slashIndex),
model: normalized.slice(slashIndex + 1)
};
}
function codexVirtualModelSupportsFusionWebSearch(
model: string,
config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>
): boolean {
return (config?.virtualModelProfiles ?? []).some((profile) =>
virtualModelIsCatalogVisible(profile) &&
virtualModelMatchesCatalogModel(profile, model, config) &&
virtualModelProfileSupportsFusionWebSearch(profile)
);
}
function virtualModelMatchesCatalogModel(
profile: VirtualModelProfileConfig,
model: string,
config?: Partial<Pick<AppConfig, "Providers" | "virtualModelProfiles">>
): boolean {
const normalizedModel = normalizeModelSelector(model);
const normalizedModelLower = normalizedModel.toLowerCase();
if (!normalizedModelLower) {
return false;
}
for (const alias of virtualModelRawCatalogNames(profile)) {
const normalizedAlias = alias.trim().toLowerCase();
if (normalizedAlias && (normalizedModelLower === normalizedAlias || normalizedModelLower === fusionModelSelector(alias).toLowerCase())) {
return true;
}
}
const selector = parseModelSelector(normalizedModel);
if (!selector) {
return false;
}
const provider = findConfiguredProvider(config, selector.provider);
if (!provider) {
return false;
}
const configuredModels = new Set(provider.models.map((item) => item.trim().toLowerCase()).filter(Boolean));
const selectedModel = selector.model.trim();
const selectedModelLower = selectedModel.toLowerCase();
for (const prefix of profile.match?.prefixes ?? []) {
const normalizedPrefix = prefix.trim();
if (!normalizedPrefix || !selectedModelLower.startsWith(normalizedPrefix.toLowerCase())) {
continue;
}
const baseModel = selectedModel.slice(normalizedPrefix.length).trim().toLowerCase();
if (configuredModels.has(baseModel)) {
return true;
}
}
for (const suffix of profile.match?.suffixes ?? []) {
const normalizedSuffix = suffix.trim();
if (!normalizedSuffix || !selectedModelLower.endsWith(normalizedSuffix.toLowerCase())) {
continue;
}
const baseModel = selectedModel.slice(0, selectedModel.length - normalizedSuffix.length).trim().toLowerCase();
if (configuredModels.has(baseModel)) {
return true;
}
}
return false;
}
function virtualModelProfileSupportsFusionWebSearch(profile: VirtualModelProfileConfig): boolean {
const metadata = recordValue(profile.metadata);
const fusionWebSearch = recordValue(metadata?.fusionWebSearch);
if (stringRecordValue(fusionWebSearch, "toolName")) {
return true;
}
if (recordValue(profile.execution)?.matchWebSearch === true) {
return true;
}
return (profile.tools ?? []).some((tool) => {
const name = tool.name.trim();
return name === BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME ||
name.startsWith(`${BUILTIN_FUSION_WEB_SEARCH_TOOL_NAME}_`);
});
}
function recordValue(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: undefined;
}
function stringRecordValue(record: Record<string, unknown> | undefined, key: string): string {
const value = record?.[key];
return typeof value === "string" ? value.trim() : "";
}
function virtualModelIsCatalogVisible(profile: VirtualModelProfileConfig): boolean {
return profile.enabled !== false &&
profile.materialization?.enabled !== false &&

View file

@ -1787,7 +1787,9 @@ function rewriteModelSelectorForProtocol(
return model;
}
const publicModel = resolveGatewayPublicModelId(normalized, config) ?? normalized;
const selector = resolveConfiguredProviderModelSelector(publicModel, config);
const selector =
resolveConfiguredProviderModelSelector(publicModel, config) ??
resolveUniqueConfiguredProviderModelSelector(publicModel, config);
const capability = selector ? providerCapabilityForClientProtocol(selector.provider, protocol) : undefined;
return selector && capability
? `${providerCapabilityInternalName(selector.provider, capability.type)}/${selector.model}`
@ -1997,7 +1999,9 @@ function prepareUpstreamCredentialAttempt(input: {
if (!target) {
return {
...input.attempt,
body: normalizedBody?.body ?? input.attempt.body,
body: bodyHasConfiguredProviderModelSelector(input.attempt.body, input.config)
? input.attempt.body
: normalizedBody?.body ?? input.attempt.body,
headers: input.headers
};
}
@ -2059,6 +2063,12 @@ function normalizeConfiguredProviderModelBody(
};
}
function bodyHasConfiguredProviderModelSelector(body: Buffer | undefined, config: AppConfig): boolean {
const parsedBody = parseJsonObjectSafe(body);
const model = stringValue(parsedBody?.model);
return Boolean(resolveConfiguredProviderModelSelector(model, config));
}
function resolveProviderCredentialRoutingTarget(
config: AppConfig,
headers: Record<string, string>,
@ -2183,6 +2193,49 @@ function resolveConfiguredProviderModelSelector(
return selectedProvider && current ? { model: current, provider: selectedProvider } : undefined;
}
function resolveUniqueConfiguredProviderModelSelector(
value: string | undefined,
config: AppConfig
): { model: string; provider: GatewayProviderConfig } | undefined {
const model = normalizeRouteSelector(value);
if (!model) {
return undefined;
}
const exactMatches = configuredProviderModelMatches(model, config, false);
if (exactMatches.length === 1) {
return exactMatches[0];
}
if (exactMatches.length > 1) {
return undefined;
}
const caseInsensitiveMatches = configuredProviderModelMatches(model, config, true);
return caseInsensitiveMatches.length === 1 ? caseInsensitiveMatches[0] : undefined;
}
function configuredProviderModelMatches(
model: string,
config: AppConfig,
caseInsensitive: boolean
): Array<{ model: string; provider: GatewayProviderConfig }> {
const normalized = caseInsensitive ? model.toLowerCase() : model;
const matches: Array<{ model: string; provider: GatewayProviderConfig }> = [];
for (const provider of config.Providers) {
for (const candidate of provider.models) {
const configuredModel = candidate.trim();
if (!configuredModel) {
continue;
}
const comparable = caseInsensitive ? configuredModel.toLowerCase() : configuredModel;
if (comparable === normalized) {
matches.push({ model: configuredModel, provider });
}
}
}
return matches;
}
function firstTargetProviderHeader(headers: Record<string, string>): string | undefined {
const provider = headers["x-target-provider"] || headers["x-gateway-target-provider"];
if (provider?.trim()) {

View file

@ -0,0 +1,169 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildCodexModelCatalog } from "../../src/main/codex-model-catalog.ts";
function catalogModelFor(config, slug) {
const catalog = buildCodexModelCatalog(config, slug);
const model = catalog.models.find((item) => item.slug === slug);
assert.ok(model, `expected catalog model ${slug}`);
return model;
}
test("codex catalog treats unknown models as text-only without advanced tools", () => {
const model = catalogModelFor({
Providers: [
{ name: "Custom", type: "openai_chat_completions", models: ["unknown-model"] }
]
}, "Custom/unknown-model");
assert.deepEqual(model.input_modalities, ["text"]);
assert.equal(model.supports_search_tool, false);
assert.equal(model.supports_parallel_tool_calls, false);
assert.equal(model.supports_reasoning_summaries, false);
assert.equal(model.supports_image_detail_original, false);
assert.deepEqual(model.supported_reasoning_levels, []);
assert.equal(model.default_reasoning_level, null);
assert.equal(model.apply_patch_tool_type, null);
});
test("codex catalog uses model catalog capabilities for known text models", () => {
const model = catalogModelFor({
Providers: [
{ name: "deepseek", type: "openai_chat_completions", models: ["deepseek-chat"] }
]
}, "deepseek/deepseek-chat");
assert.deepEqual(model.input_modalities, ["text"]);
assert.equal(model.supports_parallel_tool_calls, true);
assert.equal(model.supports_search_tool, false);
assert.equal(model.supports_reasoning_summaries, false);
assert.equal(model.supports_image_detail_original, false);
assert.deepEqual(model.supported_reasoning_levels, []);
assert.equal(model.default_reasoning_level, null);
assert.equal(model.apply_patch_tool_type, null);
});
test("codex catalog enables multimodal reasoning and search when provider protocol supports it", () => {
const model = catalogModelFor({
Providers: [
{ name: "openrouter", type: "openai_responses", models: ["google/gemini-2.5-pro"] }
]
}, "openrouter/google/gemini-2.5-pro");
assert.deepEqual(model.input_modalities, ["text", "image"]);
assert.equal(model.supports_image_detail_original, true);
assert.equal(model.supports_parallel_tool_calls, true);
assert.equal(model.supports_reasoning_summaries, true);
assert.equal(model.supports_search_tool, true);
assert.equal(model.web_search_tool_type, "text_and_image");
assert.deepEqual(model.supported_reasoning_levels.map((level) => level.effort), ["low", "medium", "high"]);
assert.equal(model.default_reasoning_level, "medium");
assert.equal(model.apply_patch_tool_type, "freeform");
});
test("codex catalog does not expose native search through chat-completions-only providers", () => {
const model = catalogModelFor({
Providers: [
{ name: "openrouter", type: "openai_chat_completions", models: ["google/gemini-2.5-pro"] }
]
}, "openrouter/google/gemini-2.5-pro");
assert.deepEqual(model.input_modalities, ["text", "image"]);
assert.equal(model.supports_parallel_tool_calls, true);
assert.equal(model.supports_reasoning_summaries, true);
assert.equal(model.supports_search_tool, false);
assert.equal(model.web_search_tool_type, "text");
assert.equal(model.apply_patch_tool_type, null);
});
test("codex catalog keeps freeform apply_patch for GPT-named chat-compatible models", () => {
const model = catalogModelFor({
Providers: [
{ name: "gateway", type: "openai_chat_completions", models: ["gpt-compatible-coder"] }
]
}, "gateway/gpt-compatible-coder");
assert.equal(model.apply_patch_tool_type, "freeform");
});
test("codex catalog keeps freeform apply_patch when provider advertises Responses capability", () => {
const model = catalogModelFor({
Providers: [
{
capabilities: [
{ baseUrl: "https://openrouter.ai/api/v1", type: "openai_chat_completions" },
{ baseUrl: "https://openrouter.ai/api/v1", type: "openai_responses" }
],
name: "openrouter",
type: "openai_chat_completions",
models: ["google/gemini-2.5-pro"]
}
]
}, "openrouter/google/gemini-2.5-pro");
assert.equal(model.apply_patch_tool_type, "freeform");
});
test("codex catalog marks Fusion aliases with builtin web search as searchable", () => {
const model = catalogModelFor({
Providers: [],
virtualModelProfiles: [
{
displayName: "Research Fusion",
enabled: true,
execution: {
clientToolsPolicy: "allow",
matchWebSearch: false,
maxToolCalls: 4,
maxTurns: 4,
mode: "tool_loop",
streamMode: "optimistic"
},
id: "research-fusion",
key: "research",
match: { exactAliases: ["research"], prefixes: [], suffixes: [] },
materialization: { enabled: true, includeInGatewayModels: true },
metadata: {
fusionWebSearch: { provider: "brave", toolName: "web_search_research" }
},
tools: [{ name: "web_search_research", visibility: "internal" }]
}
]
}, "Fusion/research");
assert.deepEqual(model.input_modalities, ["text"]);
assert.equal(model.supports_search_tool, true);
assert.equal(model.web_search_tool_type, "text");
assert.equal(model.apply_patch_tool_type, null);
});
test("codex catalog marks prefixed Fusion virtual models with legacy web search tools as searchable", () => {
const model = catalogModelFor({
Providers: [
{ name: "deepseek", type: "openai_chat_completions", models: ["deepseek-chat"] }
],
virtualModelProfiles: [
{
displayName: "Web Prefix",
enabled: true,
execution: {
clientToolsPolicy: "allow",
maxToolCalls: 4,
maxTurns: 4,
mode: "tool_loop",
streamMode: "optimistic"
},
id: "web-prefix",
key: "web-prefix",
match: { exactAliases: [], prefixes: ["web-"], suffixes: [] },
materialization: { enabled: true, includeInGatewayModels: true },
tools: [{ name: "web_search", visibility: "internal" }]
}
]
}, "deepseek/web-deepseek-chat");
assert.deepEqual(model.input_modalities, ["text"]);
assert.equal(model.supports_search_tool, true);
assert.equal(model.web_search_tool_type, "text");
assert.equal(model.apply_patch_tool_type, null);
});