dsh-vision-router 1.2.3 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -19
- package/README.zh.md +35 -19
- package/cordis.patch.yml +6 -2
- package/docs/doctor.md +19 -1
- package/docs/progressive-tools-cache.md +57 -0
- package/entry.js +51 -0
- package/index.js +317 -8
- package/lib/client.js +865 -100
- package/lib/doctor-cli.js +35 -6
- package/lib/doctor.js +106 -2
- package/lib/file-logger.js +352 -0
- package/package.json +10 -7
- package/presets/README.md +3 -0
package/index.js
CHANGED
|
@@ -233,6 +233,12 @@ export const Config = z.object({
|
|
|
233
233
|
tool: z.boolean().default(true),
|
|
234
234
|
progressiveTools: z.boolean().default(true),
|
|
235
235
|
autoActivateOnImage: z.boolean().default(true),
|
|
236
|
+
// User feedback (Zhipu official channel): some channels expose vision
|
|
237
|
+
// models whose catalog metadata does not declare image input. Models the
|
|
238
|
+
// built-in name inference does not recognize can be forced here — one model
|
|
239
|
+
// id (or "provider/model") per entry. Only consulted for vision BACKEND
|
|
240
|
+
// capability (the session-side admission stays host-owned).
|
|
241
|
+
extraVisionModels: z.array(z.string()).default([]),
|
|
236
242
|
// Client-persisted UI state (issue #78): DSH Desktop serves the Web UI from
|
|
237
243
|
// a random port on every launch, so origin-scoped localStorage forgets the
|
|
238
244
|
// first-run onboarding dialog and it re-appeared on every boot. These keys
|
|
@@ -2005,6 +2011,158 @@ export function modelInfoAcceptsImages(info) {
|
|
|
2005
2011
|
return Array.isArray(info && info.inputModalities) && info.inputModalities.includes('image')
|
|
2006
2012
|
}
|
|
2007
2013
|
|
|
2014
|
+
// User feedback: channels like the Zhipu official one (open.bigmodel.cn,
|
|
2015
|
+
// configured with a custom model list) expose vision models whose catalog
|
|
2016
|
+
// metadata does NOT declare image input, even though the models accept images
|
|
2017
|
+
// (e.g. glm-4.6v). DSH's Web settings do not write the `input: [text, image]`
|
|
2018
|
+
// declaration for custom channels either, so a strict metadata check hides
|
|
2019
|
+
// perfectly usable vision backends. The conservative, curated name patterns
|
|
2020
|
+
// below recognize well-known multimodal model families as a fallback; models
|
|
2021
|
+
// that still do not match can be forced via the `extraVisionModels` setting.
|
|
2022
|
+
// A vision-looking name does not necessarily identify a generative chat model.
|
|
2023
|
+
// Embedding and reranker endpoints often share the same VL family prefix but
|
|
2024
|
+
// cannot answer vision_describe. Keep them out of the automatic candidate
|
|
2025
|
+
// set; an explicit extraVisionModels override remains the expert escape hatch.
|
|
2026
|
+
const NON_GENERATIVE_VISION_MODEL_HINTS = [
|
|
2027
|
+
/(^|[\/_.-])(embedding|embeddings|embed)(?=$|[\/_.-])/i,
|
|
2028
|
+
/(^|[\/_.-])(rerank|reranker|reranking)(?=$|[\/_.-])/i,
|
|
2029
|
+
]
|
|
2030
|
+
|
|
2031
|
+
export function looksLikeNonGenerativeVisionModel(modelId) {
|
|
2032
|
+
const id = String(modelId ?? '').trim()
|
|
2033
|
+
if (id === '') return false
|
|
2034
|
+
return NON_GENERATIVE_VISION_MODEL_HINTS.some((pattern) => pattern.test(id))
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
const VISION_MODEL_NAME_HINTS = [
|
|
2038
|
+
// Zhipu VLM family: glm-4.6v, glm-4.6v-flash, glm-4v-plus, glm-4.5v(-plus)…
|
|
2039
|
+
/(^|\/)glm-4[\w.-]*v(?=$|[-/])/i,
|
|
2040
|
+
/(^|\/)glm-4v(?=$|[-/])/i,
|
|
2041
|
+
// Qwen VL / QVQ vision-reasoning family (excludes plain qwen3-14b etc.).
|
|
2042
|
+
/(^|\/)qwen[\w.-]*(vl|vision)/i,
|
|
2043
|
+
/(^|\/)qvq(?=$|[-.])/i,
|
|
2044
|
+
// OpenAI multimodal line (gpt-4o*, gpt-4.1*, gpt-5*, gpt-oss*).
|
|
2045
|
+
/(^|\/)gpt-(4o|4\.1|5|oss)(?=$|[-.])/i,
|
|
2046
|
+
/(^|\/)gemini/i,
|
|
2047
|
+
// Claude 3+ / Sonnet/Opus/Haiku are multimodal (claude-2 is not).
|
|
2048
|
+
/(^|\/)(claude-(3|4)(?=$|[-.])|claude[\w.-]*(sonnet|opus|haiku))/i,
|
|
2049
|
+
/(^|\/)(internvl|cogvlm|llava|pixtral)/i,
|
|
2050
|
+
/(^|\/)(doubao|hunyuan|minimax|ernie)[\w.-]*(vl|vision)/i,
|
|
2051
|
+
/(^|\/)ernie-4\.5/i,
|
|
2052
|
+
/(^|\/)(yi-vision|kimi[\w.-]*vision|moonshot[\w.-]*vision)/i,
|
|
2053
|
+
/(^|\/)step[\w.-]*(v|vision)(?=$|[-/])/i,
|
|
2054
|
+
/(^|\/)grok[\w.-]*vision/i,
|
|
2055
|
+
/(^|\/)grok-4(?=$|[-.])/i,
|
|
2056
|
+
/(^|\/)llama[\w.-]*vision/i,
|
|
2057
|
+
/(^|\/)mistral[\w.-]*pixtral/i,
|
|
2058
|
+
/(^|\/)(phi[\w.-]*vision|florence[\w.-]*)/i,
|
|
2059
|
+
]
|
|
2060
|
+
|
|
2061
|
+
/**
|
|
2062
|
+
* Conservative name-based inference for vision capability: true only when the
|
|
2063
|
+
* model id matches a well-known multimodal naming pattern. Used as a fallback
|
|
2064
|
+
* when catalog metadata does not declare image input; never overrides an
|
|
2065
|
+
* explicit text-only declaration on the session/twin paths.
|
|
2066
|
+
*/
|
|
2067
|
+
export function looksLikeVisionModel(modelId) {
|
|
2068
|
+
const id = String(modelId ?? '').trim()
|
|
2069
|
+
if (id === '' || looksLikeNonGenerativeVisionModel(id)) return false
|
|
2070
|
+
return VISION_MODEL_NAME_HINTS.some((pattern) => pattern.test(id))
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
/**
|
|
2074
|
+
* Pure capability decision for a vision backend: an explicit user override
|
|
2075
|
+
* wins first, known non-generative endpoint roles are excluded next, then
|
|
2076
|
+
* declared image metadata and conservative name inference are considered.
|
|
2077
|
+
*
|
|
2078
|
+
* @param info - resolved model metadata (may be undefined when the lookup failed).
|
|
2079
|
+
* @param provider - provider id, used to match "provider/model" override entries.
|
|
2080
|
+
* @param model - model id.
|
|
2081
|
+
* @param extraVisionModels - user-configured model ids (or "provider/model") forced vision-capable.
|
|
2082
|
+
* @returns { image, inputModalities, inferred, reason } where `inferred` is
|
|
2083
|
+
* false for declared image input, 'override' for the user list, 'name' for the
|
|
2084
|
+
* naming heuristic, and `reason` explains a text-only verdict.
|
|
2085
|
+
*/
|
|
2086
|
+
export function decideVisionBackendCapability(info, provider, model, extraVisionModels) {
|
|
2087
|
+
const inputModalities = Array.isArray(info && info.inputModalities)
|
|
2088
|
+
? info.inputModalities.filter((item) => typeof item === 'string')
|
|
2089
|
+
: []
|
|
2090
|
+
const modelId = String(model ?? '').trim()
|
|
2091
|
+
const providerId = String(provider ?? '').trim()
|
|
2092
|
+
const extras = Array.isArray(extraVisionModels)
|
|
2093
|
+
? extraVisionModels.map((entry) => String(entry ?? '').trim()).filter((entry) => entry !== '')
|
|
2094
|
+
: []
|
|
2095
|
+
const forced =
|
|
2096
|
+
modelId !== '' &&
|
|
2097
|
+
extras.some((entry) => entry === modelId || (providerId !== '' && entry === `${providerId}/${modelId}`))
|
|
2098
|
+
// Manual override is deliberately strongest: advanced users can still
|
|
2099
|
+
// force an unusual endpoint that our role/name heuristics reject.
|
|
2100
|
+
if (forced) {
|
|
2101
|
+
return { image: true, inputModalities: [...new Set([...inputModalities, 'image'])], inferred: 'override', reason: undefined }
|
|
2102
|
+
}
|
|
2103
|
+
// A model can consume images and still be the wrong KIND of endpoint
|
|
2104
|
+
// for this plugin: embedding/reranking produces no assistant answer.
|
|
2105
|
+
if (modelId !== '' && looksLikeNonGenerativeVisionModel(modelId)) {
|
|
2106
|
+
return {
|
|
2107
|
+
image: false,
|
|
2108
|
+
inputModalities,
|
|
2109
|
+
inferred: false,
|
|
2110
|
+
reason: 'model name indicates an embedding/reranker endpoint, not a generative vision backend',
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
if (inputModalities.includes('image')) {
|
|
2114
|
+
return { image: true, inputModalities, inferred: false, reason: undefined }
|
|
2115
|
+
}
|
|
2116
|
+
if (modelId !== '' && looksLikeVisionModel(modelId)) {
|
|
2117
|
+
return { image: true, inputModalities: [...new Set([...inputModalities, 'image'])], inferred: 'name', reason: undefined }
|
|
2118
|
+
}
|
|
2119
|
+
return {
|
|
2120
|
+
image: false,
|
|
2121
|
+
inputModalities,
|
|
2122
|
+
inferred: false,
|
|
2123
|
+
reason: 'model metadata does not declare image input',
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
/**
|
|
2128
|
+
* Resolve transport facts for the direct channel compatibility bridge.
|
|
2129
|
+
* Raw llm-pi-ai settings commonly omit baseURL/api for built-in catalog
|
|
2130
|
+
* providers; the materialized pi-ai model carries the effective values.
|
|
2131
|
+
*/
|
|
2132
|
+
export function resolveChannelBridgeTransport(rawProfile, resolvedProfile, modelId) {
|
|
2133
|
+
let resolvedModel
|
|
2134
|
+
try {
|
|
2135
|
+
const getModels = resolvedProfile && resolvedProfile.piProvider && resolvedProfile.piProvider.getModels
|
|
2136
|
+
const models = typeof getModels === 'function'
|
|
2137
|
+
? getModels.call(resolvedProfile.piProvider)
|
|
2138
|
+
: []
|
|
2139
|
+
resolvedModel = Array.isArray(models)
|
|
2140
|
+
? models.find((entry) => entry && String(entry.id) === String(modelId))
|
|
2141
|
+
: undefined
|
|
2142
|
+
} catch {
|
|
2143
|
+
resolvedModel = undefined
|
|
2144
|
+
}
|
|
2145
|
+
const firstString = (...values) =>
|
|
2146
|
+
values.find((value) => typeof value === 'string' && value.trim() !== '')
|
|
2147
|
+
return {
|
|
2148
|
+
baseURL: firstString(
|
|
2149
|
+
resolvedModel && resolvedModel.baseUrl,
|
|
2150
|
+
rawProfile && rawProfile.baseURL,
|
|
2151
|
+
resolvedProfile && resolvedProfile.baseURL,
|
|
2152
|
+
resolvedProfile && resolvedProfile.piProvider && resolvedProfile.piProvider.baseUrl,
|
|
2153
|
+
),
|
|
2154
|
+
api: firstString(
|
|
2155
|
+
resolvedModel && resolvedModel.api,
|
|
2156
|
+
rawProfile && rawProfile.api,
|
|
2157
|
+
resolvedProfile && resolvedProfile.api,
|
|
2158
|
+
),
|
|
2159
|
+
apiKeyEnv: firstString(
|
|
2160
|
+
rawProfile && rawProfile.apiKeyEnv,
|
|
2161
|
+
resolvedProfile && resolvedProfile.apiKeyEnv,
|
|
2162
|
+
),
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2008
2166
|
export function apply(ctx, config = {}) {
|
|
2009
2167
|
// Route sharp version diagnostics (issue #75) through the harness logger
|
|
2010
2168
|
// instead of console.warn, so the warning lands in the server log.
|
|
@@ -2640,15 +2798,13 @@ export function apply(ctx, config = {}) {
|
|
|
2640
2798
|
}
|
|
2641
2799
|
try {
|
|
2642
2800
|
const info = await ctx.llm.resolveModelInfo(provider, model)
|
|
2643
|
-
|
|
2644
|
-
? info.inputModalities.filter((item) => typeof item === 'string')
|
|
2645
|
-
: []
|
|
2646
|
-
return {
|
|
2647
|
-
image: modelInfoAcceptsImages(info),
|
|
2648
|
-
inputModalities,
|
|
2649
|
-
reason: modelInfoAcceptsImages(info) ? undefined : 'model metadata does not declare image input',
|
|
2650
|
-
}
|
|
2801
|
+
return decideVisionBackendCapability(info, provider, model, current().extraVisionModels)
|
|
2651
2802
|
} catch (error) {
|
|
2803
|
+
// Metadata lookup failed (custom model lists often do): fall back to
|
|
2804
|
+
// the user override list and the name heuristic before declaring the
|
|
2805
|
+
// model text-only.
|
|
2806
|
+
const fallback = decideVisionBackendCapability(undefined, provider, model, current().extraVisionModels)
|
|
2807
|
+
if (fallback.image) return fallback
|
|
2652
2808
|
return {
|
|
2653
2809
|
image: false,
|
|
2654
2810
|
inputModalities: [],
|
|
@@ -2657,6 +2813,123 @@ export function apply(ctx, config = {}) {
|
|
|
2657
2813
|
}
|
|
2658
2814
|
}
|
|
2659
2815
|
|
|
2816
|
+
// ── direct OpenAI-compatible bridge for undeclared vision channels ─────────
|
|
2817
|
+
//
|
|
2818
|
+
// User feedback (Zhipu official channel, open.bigmodel.cn): some channels
|
|
2819
|
+
// expose vision models whose catalog metadata does NOT declare image input,
|
|
2820
|
+
// so the channel adapter refuses image requests at the wire
|
|
2821
|
+
// (UNSUPPORTED_CONTENT: model "x" does not support image input) even though
|
|
2822
|
+
// the models accept images. For backends recognized only through the name
|
|
2823
|
+
// inference or the extraVisionModels override, fall back to calling the
|
|
2824
|
+
// channel's OpenAI-compatible endpoint directly with the channel's own
|
|
2825
|
+
// baseURL and credential — no hand-edited settings.yaml needed. Defensive
|
|
2826
|
+
// reads only: if the channel settings section, the baseURL, or the
|
|
2827
|
+
// credential cannot be resolved, the bridge is simply unavailable and the
|
|
2828
|
+
// adapter's own error is reported.
|
|
2829
|
+
const rawChannelProfileOf = (provider) => {
|
|
2830
|
+
try {
|
|
2831
|
+
const settings = ctx.get('settings')
|
|
2832
|
+
const section =
|
|
2833
|
+
settings && typeof settings.get === 'function' ? settings.get('llm-pi-ai') : undefined
|
|
2834
|
+
return section && section.providers ? section.providers[provider] : undefined
|
|
2835
|
+
} catch {
|
|
2836
|
+
return undefined
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
// DSH's public model metadata intentionally omits endpoint/protocol
|
|
2840
|
+
// details. PiAiAdapter has already materialized those facts in its
|
|
2841
|
+
// resolved profile, so feature-detect that shape as a compatibility
|
|
2842
|
+
// shim. If upstream changes it, this fails closed to the normal chain.
|
|
2843
|
+
const resolvedPiAiProfileOf = (provider) => {
|
|
2844
|
+
try {
|
|
2845
|
+
const registration = ctx.llm.registration(provider)
|
|
2846
|
+
const adapter = registration && registration.adapter
|
|
2847
|
+
const config = adapter && adapter.config
|
|
2848
|
+
const profiles = config && typeof config.profiles === 'function' ? config.profiles() : undefined
|
|
2849
|
+
return profiles && typeof profiles.get === 'function' ? profiles.get(provider) : undefined
|
|
2850
|
+
} catch {
|
|
2851
|
+
return undefined
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
const channelBridgePlan = (provider, model) => {
|
|
2855
|
+
const rawProfile = rawChannelProfileOf(provider)
|
|
2856
|
+
const resolvedProfile = resolvedPiAiProfileOf(provider)
|
|
2857
|
+
const transport = resolveChannelBridgeTransport(rawProfile, resolvedProfile, model)
|
|
2858
|
+
if (!transport.baseURL) {
|
|
2859
|
+
return { ok: false, reason: 'no resolved channel baseURL', rawProfile, resolvedProfile, transport }
|
|
2860
|
+
}
|
|
2861
|
+
// callOpenAICompatible speaks Chat Completions. Never send another
|
|
2862
|
+
// provider protocol through this bridge just because its name looks visual.
|
|
2863
|
+
if (transport.api !== 'openai-completions') {
|
|
2864
|
+
return {
|
|
2865
|
+
ok: false,
|
|
2866
|
+
reason: `channel protocol ${transport.api || 'unknown'} is not OpenAI Chat Completions`,
|
|
2867
|
+
rawProfile,
|
|
2868
|
+
resolvedProfile,
|
|
2869
|
+
transport,
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
return { ok: true, rawProfile, resolvedProfile, transport }
|
|
2873
|
+
}
|
|
2874
|
+
const resolveChannelApiKey = async (plan) => {
|
|
2875
|
+
const ref = plan && plan.transport && plan.transport.apiKeyEnv
|
|
2876
|
+
if (typeof ref === 'string' && ref !== '') {
|
|
2877
|
+
try {
|
|
2878
|
+
const credentials = ctx.get('credentials')
|
|
2879
|
+
if (credentials !== undefined) {
|
|
2880
|
+
const hit = await credentials.resolve(ref)
|
|
2881
|
+
if (hit && typeof hit.value === 'string' && hit.value.length > 0) return hit.value
|
|
2882
|
+
}
|
|
2883
|
+
} catch {
|
|
2884
|
+
/* fall through to the ambient environment */
|
|
2885
|
+
}
|
|
2886
|
+
if (typeof process !== 'undefined' && process.env && typeof process.env[ref] === 'string') {
|
|
2887
|
+
return process.env[ref]
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
// Catalog routes may use provider-native environment discovery and
|
|
2891
|
+
// therefore carry no explicit Harness credential reference.
|
|
2892
|
+
try {
|
|
2893
|
+
const auth = plan && plan.resolvedProfile && plan.resolvedProfile.piProvider
|
|
2894
|
+
&& plan.resolvedProfile.piProvider.auth && plan.resolvedProfile.piProvider.auth.apiKey
|
|
2895
|
+
if (auth && typeof auth.resolve === 'function') {
|
|
2896
|
+
const hit = await auth.resolve({ credential: undefined })
|
|
2897
|
+
const value = hit && hit.auth && hit.auth.apiKey
|
|
2898
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
2899
|
+
}
|
|
2900
|
+
} catch {
|
|
2901
|
+
/* unavailable native auth */
|
|
2902
|
+
}
|
|
2903
|
+
return undefined
|
|
2904
|
+
}
|
|
2905
|
+
const directChannelVisionAnswer = async (provider, model, blocks, instruction, signal) => {
|
|
2906
|
+
const plan = channelBridgePlan(provider, model)
|
|
2907
|
+
if (!plan.ok) throw new Error(`vision bridge unavailable: ${plan.reason}`)
|
|
2908
|
+
const apiKey = await resolveChannelApiKey(plan)
|
|
2909
|
+
if (apiKey === undefined || apiKey === '') {
|
|
2910
|
+
throw new Error('vision bridge unavailable: channel credential could not be resolved')
|
|
2911
|
+
}
|
|
2912
|
+
const attachments = ctx.get('attachments')
|
|
2913
|
+
if (attachments === undefined) {
|
|
2914
|
+
throw new Error('vision bridge unavailable: attachment service is not registered')
|
|
2915
|
+
}
|
|
2916
|
+
const content = []
|
|
2917
|
+
for (const block of blocks) {
|
|
2918
|
+
const stored = await attachments.readImage(block.attachment)
|
|
2919
|
+
content.push(...toOpenAIContent([block], () => stored.data))
|
|
2920
|
+
}
|
|
2921
|
+
return callOpenAICompatible(
|
|
2922
|
+
{
|
|
2923
|
+
name: provider,
|
|
2924
|
+
baseURL: plan.transport.baseURL,
|
|
2925
|
+
model,
|
|
2926
|
+
apiKeyEnv: '__vision-router-channel__',
|
|
2927
|
+
},
|
|
2928
|
+
[{ role: 'user', content: [...content, { type: 'text', text: instruction }] }],
|
|
2929
|
+
{ maxTokens: 4096, signal, resolveCredential: () => apiKey },
|
|
2930
|
+
)
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2660
2933
|
const collectVisionBackendCapabilities = async () => {
|
|
2661
2934
|
const capabilities = {}
|
|
2662
2935
|
if (typeof ctx.llm.listProviders !== 'function') return capabilities
|
|
@@ -3768,6 +4041,7 @@ export function apply(ctx, config = {}) {
|
|
|
3768
4041
|
const block = await visionBlocksFromBytes(imageBytes, mediaType)
|
|
3769
4042
|
const signal = AbortSignal.timeout(timeoutMs())
|
|
3770
4043
|
const usablePairs = await resolveToolVisionPairs()
|
|
4044
|
+
const pairCapabilities = new Map()
|
|
3771
4045
|
for (const pair of pairs()) {
|
|
3772
4046
|
if (!pair || pair.provider === HTTP_ROUTE) continue
|
|
3773
4047
|
if (!adapterAvailable(ctx.llm, pair.provider)) {
|
|
@@ -3775,6 +4049,7 @@ export function apply(ctx, config = {}) {
|
|
|
3775
4049
|
continue
|
|
3776
4050
|
}
|
|
3777
4051
|
const capability = await resolveVisionBackendCapability(pair.provider, pair.model)
|
|
4052
|
+
pairCapabilities.set(`${pair.provider}/${pair.model}`, capability)
|
|
3778
4053
|
if (!capability.image) {
|
|
3779
4054
|
errors.push(
|
|
3780
4055
|
`${pair.provider}/${pair.model}: not an image-capable backend (${capability.reason ?? 'unknown capability'})`,
|
|
@@ -3782,6 +4057,16 @@ export function apply(ctx, config = {}) {
|
|
|
3782
4057
|
}
|
|
3783
4058
|
}
|
|
3784
4059
|
for (const pair of usablePairs) {
|
|
4060
|
+
// usablePairs also contains auto-discovered models. Before this fix the
|
|
4061
|
+
// map was populated only from explicit config rows, so inferred
|
|
4062
|
+
// SiliconFlow models failed pi-ai image admission and never reached
|
|
4063
|
+
// the direct channel bridge that was meant to rescue them.
|
|
4064
|
+
const pairKey = `${pair.provider}/${pair.model}`
|
|
4065
|
+
let pairCapability = pairCapabilities.get(pairKey)
|
|
4066
|
+
if (pairCapability === undefined) {
|
|
4067
|
+
pairCapability = await resolveVisionBackendCapability(pair.provider, pair.model)
|
|
4068
|
+
pairCapabilities.set(pairKey, pairCapability)
|
|
4069
|
+
}
|
|
3785
4070
|
try {
|
|
3786
4071
|
const text = await visionAnswer(ctx.llm, {
|
|
3787
4072
|
provider: pair.provider,
|
|
@@ -3794,6 +4079,30 @@ export function apply(ctx, config = {}) {
|
|
|
3794
4079
|
})
|
|
3795
4080
|
if (text && text.trim() !== '') return { text: text.trim() }
|
|
3796
4081
|
} catch (error) {
|
|
4082
|
+
// Channels whose catalog does not declare image input reject images
|
|
4083
|
+
// at the adapter wire. When the backend was recognized by the name
|
|
4084
|
+
// inference or the extraVisionModels override, call the channel's
|
|
4085
|
+
// OpenAI-compatible endpoint directly with its own baseURL and
|
|
4086
|
+
// credential before giving up on this pair.
|
|
4087
|
+
const capability = pairCapabilities.get(`${pair.provider}/${pair.model}`)
|
|
4088
|
+
if (capability && capability.inferred) {
|
|
4089
|
+
try {
|
|
4090
|
+
const direct = await directChannelVisionAnswer(
|
|
4091
|
+
pair.provider,
|
|
4092
|
+
pair.model,
|
|
4093
|
+
[block],
|
|
4094
|
+
instruction,
|
|
4095
|
+
signal,
|
|
4096
|
+
)
|
|
4097
|
+
if (direct && direct.trim() !== '') return { text: direct.trim() }
|
|
4098
|
+
} catch (bridgeError) {
|
|
4099
|
+
errors.push(
|
|
4100
|
+
`${pair.provider}/${pair.model}: direct channel fallback failed (${
|
|
4101
|
+
bridgeError && bridgeError.message ? bridgeError.message : String(bridgeError)
|
|
4102
|
+
})`,
|
|
4103
|
+
)
|
|
4104
|
+
}
|
|
4105
|
+
}
|
|
3797
4106
|
errors.push(`${pair.provider}/${pair.model}: ${error && error.message ? error.message : String(error)}`)
|
|
3798
4107
|
}
|
|
3799
4108
|
}
|