diff --git a/src/utils/hooks/image-analysis-backend-resolver.ts b/src/utils/hooks/image-analysis-backend-resolver.ts index 229d6e11..8bef4ad6 100644 --- a/src/utils/hooks/image-analysis-backend-resolver.ts +++ b/src/utils/hooks/image-analysis-backend-resolver.ts @@ -8,6 +8,7 @@ import { mapExternalProviderName, } from '../../cliproxy/provider-capabilities'; import { getProviderCatalog, supportsNativeImageInput } from '../../cliproxy/model-catalog'; +import { buildCliproxyProviderPath } from '../../cliproxy/config/provider-route'; import { extractProviderFromPathname } from '../../cliproxy/ai-providers/model-id-normalizer'; import type { CliproxyBridgeMetadata } from '../../api/services/profile-types'; import type { Settings } from '../../types/config'; @@ -251,6 +252,12 @@ function getRuntimePath(backendId: string | null): string | null { return null; } + if (isCLIProxyProvider(backendId)) { + // Claude-compatible traffic and the original backend route at the + // CLIProxy root; /api/provider/ scoped routes are Plus-only. + return buildCliproxyProviderPath(backendId) || null; + } + return `/api/provider/${backendId}`; } diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status-config-default.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status-config-default.test.ts index 3f859652..e3909c8f 100644 --- a/tests/unit/utils/hooks/image-analysis-runtime-status-config-default.test.ts +++ b/tests/unit/utils/hooks/image-analysis-runtime-status-config-default.test.ts @@ -106,7 +106,9 @@ describe('resolveImageAnalysisRuntimeStatus config default', () => { expect(status.backendId).toBe('claude'); expect(status.resolutionSource).toBe('profile-backend'); expect(status.model).toBe('claude-haiku-4-5-20251001'); - expect(status.runtimePath).toBe('/api/provider/claude'); + // Claude-compatible traffic routes at the CLIProxy root; the scoped + // /api/provider/claude path is not part of the canonical route helper. + expect(status.runtimePath).toBeNull(); expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); }); }); diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status-original-backend-route.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status-original-backend-route.test.ts new file mode 100644 index 00000000..4e9a63bc --- /dev/null +++ b/tests/unit/utils/hooks/image-analysis-runtime-status-original-backend-route.test.ts @@ -0,0 +1,111 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +/** + * Regression (#1703): the original CLIProxy backend only serves the root + * /v1/messages route. Image analysis used to build + * /api/provider//v1/messages unconditionally, which 404s on original. + * The default backend is original, so the default-config path must resolve + * a null runtimePath (root route) for CLIProxy providers. + */ +describe('resolveImageAnalysisRuntimeStatus original backend route', () => { + let tmpHome = ''; + let previousCcsHome: string | undefined; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-original-route-')); + const ccsDir = join(tmpHome, '.ccs'); + mkdirSync(ccsDir, { recursive: true }); + + const settingsPath = join(ccsDir, 'deepseek.settings.json'); + writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic', + ANTHROPIC_MODEL: 'deepseek-v4-pro', + }, + }, + null, + 2 + ) + '\n' + ); + + writeFileSync( + join(ccsDir, 'config.yaml'), + [ + 'version: 14', + 'profiles:', + ' deepseek:', + ' type: api', + ` settings: ${settingsPath}`, + 'image_analysis:', + ' enabled: true', + ' timeout: 60', + ' provider_models:', + ' gemini: gemini-2.5-flash', + ' fallback_backend: gemini', + ' profile_backends:', + ' deepseek: gemini', + '', + ].join('\n') + ); + + previousCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + }); + + afterEach(() => { + if (previousCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = previousCcsHome; + } + + if (tmpHome) { + rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it('resolves the root runtime path for the original backend', async () => { + const { resolveImageAnalysisRuntimeStatus } = await import( + '../../../../src/utils/hooks/image-analysis-runtime-status' + ); + + const status = await resolveImageAnalysisRuntimeStatus( + { + profileName: 'deepseek', + profileType: 'settings', + }, + undefined, + { + checkRemoteProxy: async () => ({ reachable: true }), + fetchRemoteAuthStatus: async () => [{ provider: 'gemini', authenticated: true }], + getProxyTarget: () => ({ + host: '100.64.0.1', + port: 8317, + protocol: 'http', + isRemote: true, + }), + initializeAccounts: () => {}, + getAuthStatus: () => ({ + provider: 'gemini', + authenticated: true, + tokenDir: join(tmpHome, 'auth'), + tokenFiles: [], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => true, + } + ); + + expect(status.backendId).toBe('gemini'); + expect(status.resolutionSource).toBe('profile-backend'); + // Root route: the hook appends /v1/messages directly to the proxy base. + expect(status.runtimePath).toBeNull(); + }); +}); diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status-plus-backend-route.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status-plus-backend-route.test.ts new file mode 100644 index 00000000..6d23d83b --- /dev/null +++ b/tests/unit/utils/hooks/image-analysis-runtime-status-plus-backend-route.test.ts @@ -0,0 +1,110 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +/** + * Counterpart to the original-backend route regression (#1703): with + * cliproxy.backend: plus, non-Claude providers keep their scoped + * /api/provider/ routes for image analysis. + */ +describe('resolveImageAnalysisRuntimeStatus plus backend route', () => { + let tmpHome = ''; + let previousCcsHome: string | undefined; + + beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-plus-route-')); + const ccsDir = join(tmpHome, '.ccs'); + mkdirSync(ccsDir, { recursive: true }); + + const settingsPath = join(ccsDir, 'deepseek.settings.json'); + writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.deepseek.com/anthropic', + ANTHROPIC_MODEL: 'deepseek-v4-pro', + }, + }, + null, + 2 + ) + '\n' + ); + + writeFileSync( + join(ccsDir, 'config.yaml'), + [ + 'version: 14', + 'profiles:', + ' deepseek:', + ' type: api', + ` settings: ${settingsPath}`, + 'cliproxy:', + ' backend: plus', + 'image_analysis:', + ' enabled: true', + ' timeout: 60', + ' provider_models:', + ' gemini: gemini-2.5-flash', + ' fallback_backend: gemini', + ' profile_backends:', + ' deepseek: gemini', + '', + ].join('\n') + ); + + previousCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpHome; + }); + + afterEach(() => { + if (previousCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = previousCcsHome; + } + + if (tmpHome) { + rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it('keeps the scoped provider route for the plus backend', async () => { + const { resolveImageAnalysisRuntimeStatus } = await import( + '../../../../src/utils/hooks/image-analysis-runtime-status' + ); + + const status = await resolveImageAnalysisRuntimeStatus( + { + profileName: 'deepseek', + profileType: 'settings', + }, + undefined, + { + checkRemoteProxy: async () => ({ reachable: true }), + fetchRemoteAuthStatus: async () => [{ provider: 'gemini', authenticated: true }], + getProxyTarget: () => ({ + host: '100.64.0.1', + port: 8317, + protocol: 'http', + isRemote: true, + }), + initializeAccounts: () => {}, + getAuthStatus: () => ({ + provider: 'gemini', + authenticated: true, + tokenDir: join(tmpHome, 'auth'), + tokenFiles: [], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => true, + } + ); + + expect(status.backendId).toBe('gemini'); + expect(status.resolutionSource).toBe('profile-backend'); + expect(status.runtimePath).toBe('/api/provider/gemini'); + }); +});