diff --git a/src/main/codex-cli-middleware-runtime.ts b/src/main/codex-cli-middleware-runtime.ts index 0813c76..339d775 100644 --- a/src/main/codex-cli-middleware-runtime.ts +++ b/src/main/codex-cli-middleware-runtime.ts @@ -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 }; } diff --git a/src/main/codex-model-catalog.ts b/src/main/codex-model-catalog.ts index d901519..fc2185e 100644 --- a/src/main/codex-model-catalog.ts +++ b/src/main/codex-model-catalog.ts @@ -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>, 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> +): 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> +): 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): 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> | 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[] { + const seen = new Set(); + 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> +): boolean { + return (config?.virtualModelProfiles ?? []).some((profile) => + virtualModelIsCatalogVisible(profile) && + virtualModelMatchesCatalogModel(profile, model, config) && + virtualModelProfileSupportsFusionWebSearch(profile) + ); +} + +function virtualModelMatchesCatalogModel( + profile: VirtualModelProfileConfig, + model: string, + config?: Partial> +): 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 | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined; +} + +function stringRecordValue(record: Record | 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 && diff --git a/src/server/gateway/service.ts b/src/server/gateway/service.ts index 7dac5f1..3726623 100644 --- a/src/server/gateway/service.ts +++ b/src/server/gateway/service.ts @@ -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, @@ -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 | undefined { const provider = headers["x-target-provider"] || headers["x-gateway-target-provider"]; if (provider?.trim()) { diff --git a/tests/main/codex-model-catalog.test.mjs b/tests/main/codex-model-catalog.test.mjs new file mode 100644 index 0000000..5585183 --- /dev/null +++ b/tests/main/codex-model-catalog.test.mjs @@ -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); +});