Files
ccs/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts
T
Kenneth Wong 2b780ceae3 fix(extended-context): never write [1m] into the Fable tier key
Claude Code resolves ANTHROPIC_DEFAULT_FABLE_MODEL through a resolver that
strips the [1m] suffix before use, unlike the opus/sonnet resolvers which
pass their env value through untouched. The stripped value is then treated
as an env-supplied default and held to the standard 200k window instead of
the model's native 1M, so writing [1m] into that key costs the long context
window rather than granting it.

Measured against a Claude Code 2.1.259 session (`/context` on a live proxy
endpoint):

  ANTHROPIC_MODEL=claude-fable-5-1[1m]               -> 1m window
  ANTHROPIC_MODEL=claude-fable-5-1                   -> 200k window
  ANTHROPIC_DEFAULT_FABLE_MODEL=claude-fable-5-1[1m] -> 200k window

The fix is key-scoped rather than model-scoped, since the suffix is what
grants 1M on every other key. Adds envKeyAcceptsExtendedContextSuffix() as
the single place recording which keys reject the suffix, enforces it as a
floor inside applyExtendedContextPreferenceToAnthropicModels() so no caller
can bypass it, and strips a previously saved suffix from that key on the
next launch through the auto path.

Also extends the preference to CLAUDE_CODE_SUBAGENT_MODEL. Subagent windows
come from the same resolver as the main loop (the Explore inheritCap bounds
the model tier, not the context window), so a bare subagent model id sits at
200k while the suffixed form gets 1M. The new key list is kept separate from
ANTHROPIC_MODEL_ENV_KEYS, which also drives routing, model-id normalization
and profile validation.

Claude-Session: https://claude.ai/code/session_01NPkafQjVf4pSwPBwBistGk
2026-09-03 17:08:23 +08:00

245 lines
8.0 KiB
TypeScript

/**
* useProviderEditor Hook
* Manages query, mutation, and state logic for ProviderEditor
*/
import { useState, useMemo, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import i18n from '@/lib/i18n';
import type { SettingsResponse, UseProviderEditorReturn } from './types';
import type { ProviderCatalog } from '../provider-model-selector';
import {
applyExtendedContextPreferenceToAnthropicModels,
hasAnthropicExtendedContextEnabled,
isExtendedContextModelEnvKey,
} from '@/lib/extended-context-utils';
import { supportsExtendedContext } from '@/lib/model-catalogs';
import { isValidProvider } from '@/lib/provider-config';
/** Required env vars for CLIProxy providers (informational only - runtime fills defaults) */
const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const;
/** Check settings for missing fields (for UI warnings) */
function checkMissingFields(settings: { env?: Record<string, string> }): string[] {
const env = settings?.env || {};
return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim());
}
export function useProviderEditor(
provider: string,
catalog?: ProviderCatalog
): UseProviderEditorReturn {
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
const [conflictDialog, setConflictDialog] = useState(false);
const queryClient = useQueryClient();
// Fetch settings for this provider
const { data, isLoading, refetch } = useQuery<SettingsResponse>({
queryKey: ['settings', provider],
queryFn: async () => {
const res = await fetch(`/api/settings/${provider}/raw`);
if (!res.ok) {
const fallbackPath =
provider === 'cursor'
? `~/.ccs/cliproxy/providers/${provider}.settings.json`
: isValidProvider(provider)
? `~/.ccs/${provider}.settings.json`
: `~/.ccs/profiles/${provider}/settings.json`;
// Return empty settings for unconfigured providers
return {
profile: provider,
settings: { env: {} },
mtime: Date.now(),
path: fallbackPath,
};
}
return res.json();
},
});
const settings = data?.settings;
// Derive raw JSON content
const rawJsonContent = useMemo(() => {
if (rawJsonEdits !== null) return rawJsonEdits;
if (settings) return JSON.stringify(settings, null, 2);
return '{\n "env": {}\n}';
}, [rawJsonEdits, settings]);
const handleRawJsonChange = useCallback((value: string) => {
setRawJsonEdits(value);
}, []);
// Parse current settings from JSON
const currentSettings = useMemo(() => {
try {
return JSON.parse(rawJsonContent);
} catch {
return settings || { env: {} };
}
}, [rawJsonContent, settings]);
// Extract model values from settings
const currentModel = currentSettings?.env?.ANTHROPIC_MODEL;
const opusModel = currentSettings?.env?.ANTHROPIC_DEFAULT_OPUS_MODEL;
const sonnetModel = currentSettings?.env?.ANTHROPIC_DEFAULT_SONNET_MODEL;
const haikuModel = currentSettings?.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL;
const fableModel = currentSettings?.env?.ANTHROPIC_DEFAULT_FABLE_MODEL;
// Extended context is enabled if any model has [1m] suffix
const extendedContextEnabled = useMemo(() => {
return hasAnthropicExtendedContextEnabled(currentSettings?.env || {});
}, [currentSettings]);
const applySavedLongContextIntent = useCallback(
(env: Record<string, string>, enabled: boolean) =>
applyExtendedContextPreferenceToAnthropicModels(env, enabled, {
supportsExtendedContext: (modelId) => supportsExtendedContext(provider, modelId, catalog),
}),
[catalog, provider]
);
// Update a single setting value
const updateEnvValue = useCallback(
(key: string, value: string) => {
const newEnv = { ...(currentSettings?.env || {}), [key]: value };
const envWithIntent = isExtendedContextModelEnvKey(key)
? applySavedLongContextIntent(newEnv, extendedContextEnabled)
: newEnv;
delete envWithIntent['CCS_EXTENDED_CONTEXT'];
const newSettings = { ...currentSettings, env: envWithIntent };
setRawJsonEdits(JSON.stringify(newSettings, null, 2));
},
[applySavedLongContextIntent, currentSettings, extendedContextEnabled]
);
// Toggle extended context - applies/strips [1m] suffix to all model env vars
const toggleExtendedContext = useCallback(
(enabled: boolean) => {
const env = currentSettings?.env || {};
const newEnv = applySavedLongContextIntent(env, enabled);
delete newEnv['CCS_EXTENDED_CONTEXT'];
const newSettings = { ...currentSettings, env: newEnv };
setRawJsonEdits(JSON.stringify(newSettings, null, 2));
},
[applySavedLongContextIntent, currentSettings]
);
// Batch update multiple env values at once
const updateEnvValues = useCallback(
(updates: Record<string, string>) => {
const newEnv = { ...(currentSettings?.env || {}), ...updates };
const touchesAnthropicModel = Object.keys(updates).some(isExtendedContextModelEnvKey);
const envWithIntent = touchesAnthropicModel
? applySavedLongContextIntent(newEnv, extendedContextEnabled)
: newEnv;
delete envWithIntent['CCS_EXTENDED_CONTEXT'];
const newSettings = { ...currentSettings, env: envWithIntent };
setRawJsonEdits(JSON.stringify(newSettings, null, 2));
},
[applySavedLongContextIntent, currentSettings, extendedContextEnabled]
);
// Check if JSON is valid
const isRawJsonValid = useMemo(() => {
try {
JSON.parse(rawJsonContent);
return true;
} catch {
return false;
}
}, [rawJsonContent]);
// Check for unsaved changes
const hasChanges = useMemo(() => {
if (rawJsonEdits === null) return false;
return rawJsonEdits !== JSON.stringify(settings, null, 2);
}, [rawJsonEdits, settings]);
// Validation state for missing required fields (informational warning)
const missingFields = useMemo(() => checkMissingFields(currentSettings), [currentSettings]);
// Save mutation (no blocking validation - runtime uses defaults)
const saveMutation = useMutation({
mutationFn: async () => {
const settingsToSave = JSON.parse(rawJsonContent);
const res = await fetch(`/api/settings/${provider}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
settings: settingsToSave,
expectedMtime: data?.mtime,
}),
});
if (res.status === 409) throw new Error('CONFLICT');
if (!res.ok) throw new Error('Failed to save');
return res.json();
},
onSuccess: (responseData) => {
queryClient.invalidateQueries({ queryKey: ['settings', provider] });
setRawJsonEdits(null);
// Show warning if fields missing (runtime uses defaults)
if (responseData?.warning) {
toast.success(i18n.t('settings.saved'), {
description: responseData.warning,
});
} else {
toast.success(i18n.t('settings.saved'));
}
},
onError: (error: Error) => {
if (error.message === 'CONFLICT') {
setConflictDialog(true);
} else {
toast.error(error.message);
}
},
});
const handleConflictResolve = async (overwrite: boolean) => {
setConflictDialog(false);
if (overwrite) {
await refetch();
saveMutation.mutate();
} else {
setRawJsonEdits(null);
}
};
return {
data,
isLoading,
refetch,
rawJsonContent,
rawJsonEdits,
isRawJsonValid,
hasChanges,
currentSettings,
currentModel,
opusModel,
sonnetModel,
haikuModel,
fableModel,
extendedContextEnabled,
toggleExtendedContext,
handleRawJsonChange,
updateEnvValue,
updateEnvValues,
saveMutation: {
mutate: () => saveMutation.mutate(),
isPending: saveMutation.isPending,
},
conflictDialog,
setConflictDialog,
handleConflictResolve,
// Validation (informational)
missingRequiredFields: missingFields,
};
}