fix(image-analysis): honor configured profile_backends at launch

resolveImageAnalysisRuntimeStatus defaulted to DEFAULT_IMAGE_ANALYSIS_CONFIG
when callers omitted the config argument. That constant ships empty
profile_backends and a gemini fallback_backend, so the launch paths that call
it without a config (settings profile dispatch and headless delegation) never
saw user-configured mappings.

A profile mapped to another backend still resolved to gemini, failed the
Gemini auth check, and silently dropped to native Read. Profiles whose model
has no vision support could not read images at all, even with a reachable
CLIProxy and an authenticated backend.

getImageAnalysisHookEnv already reads the saved config, so the launch env and
the runtime status disagreed on the same launch: CCS_IMAGE_ANALYSIS_BACKEND_ID
carried the mapped backend while the status object reported native-read.

Default to getImageAnalysisConfig() so both read the same source. Callers that
pass an explicit config keep their existing behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Aaron VuandClaude committed 2026-08-17 15:27:02 +07:00
1 parent a0990e0ef1
commit fe3447f487
3 files changed
+130 -8

No files matched your search

@@ -16,10 +16,8 @@ import { getProxyTarget, type ProxyTarget } from '../../cliproxy/proxy/proxy-tar
import { getProviderDisplayName, isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
import { isCliproxyRunning } from '../../cliproxy/services/stats-fetcher';
import type { CLIProxyProvider } from '../../cliproxy/types';
import {
DEFAULT_IMAGE_ANALYSIS_CONFIG,
type ImageAnalysisConfig,
} from '../../config/unified-config-types';
import { getImageAnalysisConfig } from '../../config/config-loader-facade';
import { type ImageAnalysisConfig } from '../../config/unified-config-types';
import {
resolveImageAnalysisStatus,
type ImageAnalysisResolutionContext,
@@ -191,9 +189,14 @@ export async function hydrateImageAnalysisRuntimeStatus(
export async function resolveImageAnalysisRuntimeStatus(
context: ImageAnalysisResolutionContext,
config: ImageAnalysisConfig = DEFAULT_IMAGE_ANALYSIS_CONFIG,
config?: ImageAnalysisConfig,
deps: Partial<ImageAnalysisRuntimeStatusDeps> = {}
): Promise<ImageAnalysisStatus> {
const baseStatus = resolveImageAnalysisStatus(context, config);
// Fall back to the user's saved image_analysis config, not the built-in
// constant. Launch paths call this without an explicit config, and the
// constant carries empty profile_backends plus a gemini fallback_backend,
// so user mappings were dropped and every profile resolved to gemini.
const resolvedConfig = config ?? getImageAnalysisConfig();
const baseStatus = resolveImageAnalysisStatus(context, resolvedConfig);
return hydrateImageAnalysisRuntimeStatus(baseStatus, deps);
}
@@ -89,8 +89,15 @@ describe('image-analysis-runtime-status circular dependency regression', () => {
'../../cliproxy/services/stats-fetcher': {
isCliproxyRunning: async () => true,
},
'../../config/unified-config-types': {
DEFAULT_IMAGE_ANALYSIS_CONFIG: {},
'../../config/unified-config-types': {},
'../../config/config-loader-facade': {
getImageAnalysisConfig: () => ({
enabled: true,
timeout: 60,
provider_models: { ghcp: 'claude-haiku-4.5' },
fallback_backend: 'ghcp',
profile_backends: {},
}),
},
'./image-analysis-backend-resolver': {
resolveImageAnalysisStatus: () => createStatus(),
@@ -0,0 +1,112 @@
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: resolveImageAnalysisRuntimeStatus used to default to the built-in
* DEFAULT_IMAGE_ANALYSIS_CONFIG constant, which carries empty profile_backends
* and a gemini fallback_backend. Launch paths call it without an explicit
* config, so user-configured profile_backends were dropped and every settings
* profile resolved to gemini, then bailed to native Read on missing Gemini auth.
*/
describe('resolveImageAnalysisRuntimeStatus config default', () => {
let tmpHome = '';
let previousCcsHome: string | undefined;
beforeEach(() => {
tmpHome = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-config-default-'));
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:',
' claude: claude-haiku-4-5-20251001',
' fallback_backend: claude',
' profile_backends:',
' deepseek: claude',
'',
].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('honors user profile_backends when no explicit config is passed', 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: 'claude', authenticated: true }],
getProxyTarget: () => ({
host: '100.64.0.1',
port: 8317,
protocol: 'http',
isRemote: true,
}),
initializeAccounts: () => {},
getAuthStatus: () => ({
provider: 'claude',
authenticated: true,
tokenDir: join(tmpHome, 'auth'),
tokenFiles: [],
accounts: [],
defaultAccount: undefined,
}),
isCliproxyRunning: async () => true,
}
);
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');
expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis');
});
});