mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 04:14:57 +00:00
fix(cliproxy): normalize provider-aware Claude model IDs
This commit is contained in:
1 parent
6a9abd2a48
commit
6429781e8f
13 files changed
+408
-32
No files matched your search
@@ -19,6 +19,11 @@ import {
|
|||||||
CLIPROXY_DEFAULT_PORT,
|
CLIPROXY_DEFAULT_PORT,
|
||||||
} from './port-manager';
|
} from './port-manager';
|
||||||
import { getProviderSettingsPath } from './path-resolver';
|
import { getProviderSettingsPath } from './path-resolver';
|
||||||
|
import {
|
||||||
|
MODEL_ENV_VAR_KEYS,
|
||||||
|
normalizeModelEnvVarsForProvider,
|
||||||
|
normalizeModelIdForProvider,
|
||||||
|
} from '../model-id-normalizer';
|
||||||
|
|
||||||
/** Settings file structure for user overrides */
|
/** Settings file structure for user overrides */
|
||||||
interface ProviderSettings {
|
interface ProviderSettings {
|
||||||
@@ -30,14 +35,6 @@ const DEPRECATED_MODEL_PREFIX = 'gemini-claude-';
|
|||||||
/** Replacement prefix matching actual upstream model names */
|
/** Replacement prefix matching actual upstream model names */
|
||||||
const UPSTREAM_MODEL_PREFIX = 'claude-';
|
const UPSTREAM_MODEL_PREFIX = 'claude-';
|
||||||
|
|
||||||
/** Env vars that contain model names and may need migration */
|
|
||||||
const MODEL_ENV_KEYS = [
|
|
||||||
'ANTHROPIC_MODEL',
|
|
||||||
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
||||||
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
||||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file.
|
* Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file.
|
||||||
* CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention.
|
* CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention.
|
||||||
@@ -49,7 +46,7 @@ function migrateDeprecatedModelNames(settingsPath: string, settings: ProviderSet
|
|||||||
if (!settings.env || typeof settings.env !== 'object') return false;
|
if (!settings.env || typeof settings.env !== 'object') return false;
|
||||||
|
|
||||||
let migrated = false;
|
let migrated = false;
|
||||||
for (const key of MODEL_ENV_KEYS) {
|
for (const key of MODEL_ENV_VAR_KEYS) {
|
||||||
const value = settings.env[key];
|
const value = settings.env[key];
|
||||||
if (typeof value !== 'string') continue;
|
if (typeof value !== 'string') continue;
|
||||||
|
|
||||||
@@ -124,10 +121,12 @@ export function getClaudeEnvVars(
|
|||||||
} = baseEnvVars;
|
} = baseEnvVars;
|
||||||
|
|
||||||
// Merge core env vars with additional env vars from base config
|
// Merge core env vars with additional env vars from base config
|
||||||
return {
|
const mergedEnv = {
|
||||||
...coreEnvVars,
|
...coreEnvVars,
|
||||||
...additionalEnvVars, // Includes ANTHROPIC_MAX_TOKENS, etc.
|
...additionalEnvVars, // Includes ANTHROPIC_MAX_TOKENS, etc.
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return normalizeModelEnvVarsForProvider(mergedEnv, provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -174,7 +173,7 @@ function ensureRequiredEnvVars(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return normalizeModelEnvVarsForProvider(result, provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Localhost hostnames used for local CLIProxy endpoints */
|
/** Localhost hostnames used for local CLIProxy endpoints */
|
||||||
@@ -458,7 +457,7 @@ export function getRemoteEnvVars(
|
|||||||
ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || getEffectiveApiKey(),
|
ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || getEffectiveApiKey(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return env;
|
return normalizeModelEnvVarsForProvider(env, provider) as Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remote config for composite variant (passed from env-resolver) */
|
/** Remote config for composite variant (passed from env-resolver) */
|
||||||
@@ -519,10 +518,19 @@ export function getCompositeEnvVars(
|
|||||||
const validPort = validatePort(port);
|
const validPort = validatePort(port);
|
||||||
|
|
||||||
// Defensive: handle missing tiers gracefully
|
// Defensive: handle missing tiers gracefully
|
||||||
const opusModel = tiers.opus?.model;
|
const opusModel = tiers.opus?.model
|
||||||
const sonnetModel = tiers.sonnet?.model;
|
? normalizeModelIdForProvider(tiers.opus.model, tiers.opus.provider)
|
||||||
const haikuModel = tiers.haiku?.model;
|
: undefined;
|
||||||
const defaultModel = tiers[defaultTier]?.model;
|
const sonnetModel = tiers.sonnet?.model
|
||||||
|
? normalizeModelIdForProvider(tiers.sonnet.model, tiers.sonnet.provider)
|
||||||
|
: undefined;
|
||||||
|
const haikuModel = tiers.haiku?.model
|
||||||
|
? normalizeModelIdForProvider(tiers.haiku.model, tiers.haiku.provider)
|
||||||
|
: undefined;
|
||||||
|
const defaultTierModel = tiers[defaultTier];
|
||||||
|
const defaultModel = defaultTierModel?.model
|
||||||
|
? normalizeModelIdForProvider(defaultTierModel.model, defaultTierModel.provider)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// If default tier is missing, we cannot proceed meaningfully
|
// If default tier is missing, we cannot proceed meaningfully
|
||||||
if (!defaultModel) {
|
if (!defaultModel) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ThinkingConfig, DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/uni
|
|||||||
import { getThinkingConfig } from '../../config/unified-config-loader';
|
import { getThinkingConfig } from '../../config/unified-config-loader';
|
||||||
import { supportsThinking } from '../model-catalog';
|
import { supportsThinking } from '../model-catalog';
|
||||||
import { isThinkingOffValue, validateThinking } from '../thinking-validator';
|
import { isThinkingOffValue, validateThinking } from '../thinking-validator';
|
||||||
|
import { normalizeModelIdForProvider } from '../model-id-normalizer';
|
||||||
import { warn } from '../../utils/ui';
|
import { warn } from '../../utils/ui';
|
||||||
|
|
||||||
/** Model tier types for thinking budget defaults */
|
/** Model tier types for thinking budget defaults */
|
||||||
@@ -19,22 +20,23 @@ export type ModelTier = 'opus' | 'sonnet' | 'haiku';
|
|||||||
*/
|
*/
|
||||||
function normalizeModelForThinkingLookup(model: string, provider: CLIProxyProvider): string {
|
function normalizeModelForThinkingLookup(model: string, provider: CLIProxyProvider): string {
|
||||||
const withoutExtendedContext = model.replace(/\[1m\]$/i, '').trim();
|
const withoutExtendedContext = model.replace(/\[1m\]$/i, '').trim();
|
||||||
|
const providerNormalized = normalizeModelIdForProvider(withoutExtendedContext, provider);
|
||||||
|
|
||||||
if (provider !== 'codex') return withoutExtendedContext;
|
if (provider !== 'codex') return providerNormalized;
|
||||||
|
|
||||||
// New codex suffix form: gpt-5.3-codex-high -> gpt-5.3-codex
|
// New codex suffix form: gpt-5.3-codex-high -> gpt-5.3-codex
|
||||||
const codexSuffixMatch = withoutExtendedContext.match(/^(.*)-(xhigh|high|medium)$/i);
|
const codexSuffixMatch = providerNormalized.match(/^(.*)-(xhigh|high|medium)$/i);
|
||||||
if (codexSuffixMatch?.[1]) {
|
if (codexSuffixMatch?.[1]) {
|
||||||
return codexSuffixMatch[1].trim();
|
return codexSuffixMatch[1].trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy codex suffix form: gpt-5.3-codex(high) -> gpt-5.3-codex
|
// Legacy codex suffix form: gpt-5.3-codex(high) -> gpt-5.3-codex
|
||||||
const codexLegacyMatch = withoutExtendedContext.match(/^(.*)\((xhigh|high|medium)\)$/i);
|
const codexLegacyMatch = providerNormalized.match(/^(.*)\((xhigh|high|medium)\)$/i);
|
||||||
if (codexLegacyMatch?.[1]) {
|
if (codexLegacyMatch?.[1]) {
|
||||||
return codexLegacyMatch[1].trim();
|
return codexLegacyMatch[1].trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
return withoutExtendedContext;
|
return providerNormalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { stripClaudeCodeEnv } from '../../utils/shell-executor';
|
|||||||
import { CodexReasoningProxy } from '../codex-reasoning-proxy';
|
import { CodexReasoningProxy } from '../codex-reasoning-proxy';
|
||||||
import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
|
import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
|
||||||
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
||||||
|
import { normalizeModelIdForProvider } from '../model-id-normalizer';
|
||||||
|
|
||||||
export interface RemoteProxyConfig {
|
export interface RemoteProxyConfig {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -271,10 +272,11 @@ export function applyFallback(
|
|||||||
} as const;
|
} as const;
|
||||||
const result = { ...env };
|
const result = { ...env };
|
||||||
const originalModel = result[tierEnvMap[failedTier]];
|
const originalModel = result[tierEnvMap[failedTier]];
|
||||||
result[tierEnvMap[failedTier]] = fallback.model;
|
const normalizedFallbackModel = normalizeModelIdForProvider(fallback.model, fallback.provider);
|
||||||
|
result[tierEnvMap[failedTier]] = normalizedFallbackModel;
|
||||||
// If failed tier is default tier, also update ANTHROPIC_MODEL
|
// If failed tier is default tier, also update ANTHROPIC_MODEL
|
||||||
if (result.ANTHROPIC_MODEL === originalModel) {
|
if (result.ANTHROPIC_MODEL === originalModel) {
|
||||||
result.ANTHROPIC_MODEL = fallback.model;
|
result.ANTHROPIC_MODEL = normalizedFallbackModel;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -84,6 +84,16 @@ export {
|
|||||||
// Model catalog and configuration
|
// Model catalog and configuration
|
||||||
export type { ModelEntry, ProviderCatalog } from './model-catalog';
|
export type { ModelEntry, ProviderCatalog } from './model-catalog';
|
||||||
export { MODEL_CATALOG, supportsModelConfig, getProviderCatalog, findModel } from './model-catalog';
|
export { MODEL_CATALOG, supportsModelConfig, getProviderCatalog, findModel } from './model-catalog';
|
||||||
|
export {
|
||||||
|
MODEL_ENV_VAR_KEYS,
|
||||||
|
extractProviderFromPathname,
|
||||||
|
isAntigravityProvider,
|
||||||
|
normalizeClaudeDottedMajorMinor,
|
||||||
|
normalizeClaudeDottedThinkingMajorMinor,
|
||||||
|
normalizeModelIdForProvider,
|
||||||
|
normalizeModelIdForRouting,
|
||||||
|
normalizeModelEnvVarsForProvider,
|
||||||
|
} from './model-id-normalizer';
|
||||||
export {
|
export {
|
||||||
hasUserSettings,
|
hasUserSettings,
|
||||||
getCurrentModel,
|
getCurrentModel,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { CLIProxyProvider } from './types';
|
import { CLIProxyProvider } from './types';
|
||||||
|
import { normalizeModelIdForProvider } from './model-id-normalizer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thinking support configuration for a model.
|
* Thinking support configuration for a model.
|
||||||
@@ -322,7 +323,12 @@ export function findModel(provider: CLIProxyProvider, modelId: string): ModelEnt
|
|||||||
const catalog = MODEL_CATALOG[provider];
|
const catalog = MODEL_CATALOG[provider];
|
||||||
if (!catalog || !modelId) return undefined;
|
if (!catalog || !modelId) return undefined;
|
||||||
const normalizedId = modelId.trim().toLowerCase();
|
const normalizedId = modelId.trim().toLowerCase();
|
||||||
return catalog.models.find((m) => m.id.toLowerCase() === normalizedId);
|
const providerNormalizedId = normalizeModelIdForProvider(normalizedId, provider)
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const lookupCandidates = new Set([normalizedId, providerNormalizedId]);
|
||||||
|
|
||||||
|
return catalog.models.find((m) => lookupCandidates.has(m.id.toLowerCase()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Model ID normalization helpers.
|
||||||
|
*
|
||||||
|
* Handles provider-aware compatibility between dotted and hyphenated Claude
|
||||||
|
* model version formats (e.g., 4.6 vs 4-6).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CLIProxyProvider } from './types';
|
||||||
|
|
||||||
|
/** Env vars that carry model identifiers. */
|
||||||
|
export const MODEL_ENV_VAR_KEYS = [
|
||||||
|
'ANTHROPIC_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type ProviderLike = CLIProxyProvider | string | null | undefined;
|
||||||
|
|
||||||
|
const CLAUDE_DOTTED_VERSION_REGEX = /claude-(sonnet|opus|haiku)-(\d+)\.(\d+)(?=(?:$|-|\[|\(|\/))/gi;
|
||||||
|
const CLAUDE_DOTTED_THINKING_REGEX =
|
||||||
|
/claude-(sonnet|opus|haiku)-(\d+)\.(\d+)-thinking(?=(?:$|-|\[|\(|\/))/gi;
|
||||||
|
|
||||||
|
/** Extract provider segment from /api/provider/{provider} paths. */
|
||||||
|
export function extractProviderFromPathname(pathname: string): string | null {
|
||||||
|
const match = pathname.match(/\/api\/provider\/([^/]+)/i);
|
||||||
|
if (!match?.[1]) return null;
|
||||||
|
return match[1].toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the provider uses Antigravity model routing conventions. */
|
||||||
|
export function isAntigravityProvider(provider: ProviderLike): boolean {
|
||||||
|
if (typeof provider !== 'string') return false;
|
||||||
|
const normalized = provider.trim().toLowerCase();
|
||||||
|
return normalized === 'agy' || normalized === 'antigravity';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize Claude dotted major.minor IDs to hyphenated format. */
|
||||||
|
export function normalizeClaudeDottedMajorMinor(model: string): string {
|
||||||
|
return model.replace(
|
||||||
|
CLAUDE_DOTTED_VERSION_REGEX,
|
||||||
|
(_match: string, family: string, major: string, minor: string) =>
|
||||||
|
`claude-${family.toLowerCase()}-${major}-${minor}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize only dotted Claude thinking IDs to hyphenated format.
|
||||||
|
* Keeps non-thinking dotted IDs unchanged.
|
||||||
|
*/
|
||||||
|
export function normalizeClaudeDottedThinkingMajorMinor(model: string): string {
|
||||||
|
return model.replace(
|
||||||
|
CLAUDE_DOTTED_THINKING_REGEX,
|
||||||
|
(_match: string, family: string, major: string, minor: string) =>
|
||||||
|
`claude-${family.toLowerCase()}-${major}-${minor}-thinking`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize model ID for a specific provider.
|
||||||
|
* Antigravity requires hyphenated Claude major.minor model IDs.
|
||||||
|
*/
|
||||||
|
export function normalizeModelIdForProvider(model: string, provider: ProviderLike): string {
|
||||||
|
if (!isAntigravityProvider(provider)) return model;
|
||||||
|
return normalizeClaudeDottedMajorMinor(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize model ID for request routing.
|
||||||
|
* - Antigravity routes: normalize all dotted Claude major.minor forms.
|
||||||
|
* - Root/composite routes: normalize only thinking forms to avoid mutating
|
||||||
|
* valid non-thinking dotted IDs used by other providers.
|
||||||
|
*/
|
||||||
|
export function normalizeModelIdForRouting(model: string, provider: ProviderLike): string {
|
||||||
|
if (isAntigravityProvider(provider)) {
|
||||||
|
return normalizeClaudeDottedMajorMinor(model);
|
||||||
|
}
|
||||||
|
return normalizeClaudeDottedThinkingMajorMinor(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize model-related env vars for a provider.
|
||||||
|
* Returns original object when no changes are required.
|
||||||
|
*/
|
||||||
|
export function normalizeModelEnvVarsForProvider(
|
||||||
|
envVars: NodeJS.ProcessEnv,
|
||||||
|
provider: ProviderLike,
|
||||||
|
keys: readonly string[] = MODEL_ENV_VAR_KEYS
|
||||||
|
): NodeJS.ProcessEnv {
|
||||||
|
let nextEnv: NodeJS.ProcessEnv | null = null;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = envVars[key];
|
||||||
|
if (typeof value !== 'string' || value.trim().length === 0) continue;
|
||||||
|
|
||||||
|
const normalizedValue = normalizeModelIdForProvider(value, provider);
|
||||||
|
if (normalizedValue === value) continue;
|
||||||
|
|
||||||
|
if (!nextEnv) nextEnv = { ...envVars };
|
||||||
|
nextEnv[key] = normalizedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextEnv ?? envVars;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import * as os from 'os';
|
|||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
import { ToolNameMapper, type Tool, type ContentBlock } from './tool-name-mapper';
|
import { ToolNameMapper, type Tool, type ContentBlock } from './tool-name-mapper';
|
||||||
import { sanitizeToolSchemas } from './schema-sanitizer';
|
import { sanitizeToolSchemas } from './schema-sanitizer';
|
||||||
|
import { extractProviderFromPathname, normalizeModelIdForRouting } from './model-id-normalizer';
|
||||||
import { getCcsDir } from '../utils/config-manager';
|
import { getCcsDir } from '../utils/config-manager';
|
||||||
|
|
||||||
export interface ToolSanitizationProxyConfig {
|
export interface ToolSanitizationProxyConfig {
|
||||||
@@ -206,12 +207,25 @@ export class ToolSanitizationProxy {
|
|||||||
// Create mapper for this request
|
// Create mapper for this request
|
||||||
const mapper = new ToolNameMapper();
|
const mapper = new ToolNameMapper();
|
||||||
|
|
||||||
// Sanitize tools if present
|
// Normalize dotted Claude model IDs for provider-compatible routing.
|
||||||
let modifiedBody = parsed;
|
let modifiedBody = parsed;
|
||||||
if (isRecord(parsed) && Array.isArray(parsed.tools)) {
|
if (isRecord(modifiedBody) && typeof modifiedBody.model === 'string') {
|
||||||
|
const providerFromPath = extractProviderFromPathname(fullUpstreamUrl.pathname);
|
||||||
|
const normalizedModel = normalizeModelIdForRouting(modifiedBody.model, providerFromPath);
|
||||||
|
if (normalizedModel !== modifiedBody.model) {
|
||||||
|
this.writeLog(
|
||||||
|
'warn',
|
||||||
|
`[tool-sanitization-proxy] Model normalized for provider routing (${providerFromPath ?? 'root'}): "${modifiedBody.model}" → "${normalizedModel}"`
|
||||||
|
);
|
||||||
|
modifiedBody = { ...modifiedBody, model: normalizedModel };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize tools if present
|
||||||
|
if (isRecord(modifiedBody) && Array.isArray(modifiedBody.tools)) {
|
||||||
// Step 1: Sanitize input_schema properties (remove non-standard JSON Schema properties)
|
// Step 1: Sanitize input_schema properties (remove non-standard JSON Schema properties)
|
||||||
const schemaResult = sanitizeToolSchemas(
|
const schemaResult = sanitizeToolSchemas(
|
||||||
parsed.tools as Array<{ name: string; input_schema?: Record<string, unknown> }>
|
modifiedBody.tools as Array<{ name: string; input_schema?: Record<string, unknown> }>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (schemaResult.totalRemoved > 0) {
|
if (schemaResult.totalRemoved > 0) {
|
||||||
@@ -228,7 +242,7 @@ export class ToolSanitizationProxy {
|
|||||||
|
|
||||||
// Step 2: Sanitize tool names (truncate to 64 chars for Gemini)
|
// Step 2: Sanitize tool names (truncate to 64 chars for Gemini)
|
||||||
const sanitizedTools = mapper.registerTools(schemaResult.tools as Tool[]);
|
const sanitizedTools = mapper.registerTools(schemaResult.tools as Tool[]);
|
||||||
modifiedBody = { ...parsed, tools: sanitizedTools };
|
modifiedBody = { ...modifiedBody, tools: sanitizedTools };
|
||||||
|
|
||||||
// Log sanitization warnings
|
// Log sanitization warnings
|
||||||
if (mapper.hasChanges()) {
|
if (mapper.hasChanges()) {
|
||||||
@@ -252,7 +266,7 @@ export class ToolSanitizationProxy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if streaming is requested
|
// Check if streaming is requested
|
||||||
const isStreaming = isRecord(parsed) && parsed.stream === true;
|
const isStreaming = isRecord(modifiedBody) && modifiedBody.stream === true;
|
||||||
|
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
await this.forwardJsonStreaming(req, res, fullUpstreamUrl, modifiedBody, mapper);
|
await this.forwardJsonStreaming(req, res, fullUpstreamUrl, modifiedBody, mapper);
|
||||||
|
|||||||
@@ -71,4 +71,25 @@ describe('buildClaudeEnvironment - composite remote routing', () => {
|
|||||||
// Gemini thinking overrides may be normalized to numeric budgets, and [1m] may be auto-appended.
|
// Gemini thinking overrides may be normalized to numeric budgets, and [1m] may be auto-appended.
|
||||||
expect(env.ANTHROPIC_MODEL).toMatch(/\([^)]+\)(\[1m\])?$/);
|
expect(env.ANTHROPIC_MODEL).toMatch(/\([^)]+\)(\[1m\])?$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes dotted Claude model IDs for antigravity tiers in composite mode', () => {
|
||||||
|
const env = buildClaudeEnvironment({
|
||||||
|
provider: 'agy',
|
||||||
|
useRemoteProxy: false,
|
||||||
|
localPort: 8318,
|
||||||
|
verbose: false,
|
||||||
|
isComposite: true,
|
||||||
|
compositeTiers: {
|
||||||
|
opus: { provider: 'agy', model: 'claude-opus-4.6-thinking' },
|
||||||
|
sonnet: { provider: 'gemini', model: 'gemini-2.5-pro' },
|
||||||
|
haiku: { provider: 'codex', model: 'gpt-5.1-codex-mini' },
|
||||||
|
},
|
||||||
|
compositeDefaultTier: 'opus',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(env.ANTHROPIC_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/);
|
||||||
|
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/);
|
||||||
|
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toMatch(/^gemini-2.5-pro(\([^)]+\))?$/);
|
||||||
|
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -378,6 +378,25 @@ describe('applyThinkingConfig - composite variant integration', () => {
|
|||||||
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)');
|
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles dotted agy Claude IDs for thinking capability lookup', () => {
|
||||||
|
const envVars: NodeJS.ProcessEnv = {
|
||||||
|
ANTHROPIC_MODEL: 'claude-opus-4.5-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.5-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.5-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = applyThinkingConfig(envVars, 'agy' as CLIProxyProvider, 'high');
|
||||||
|
|
||||||
|
// Capability lookup should succeed for dotted agy IDs after normalization.
|
||||||
|
// Main model value is validated for budget models; tier values keep raw override.
|
||||||
|
expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4.5-thinking(24576)');
|
||||||
|
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4.5-thinking(high)');
|
||||||
|
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4.5-thinking(high)');
|
||||||
|
// Haiku does not support thinking in agy catalog.
|
||||||
|
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4.5');
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle numeric budgets in per-tier thinking', () => {
|
it('should handle numeric budgets in per-tier thinking', () => {
|
||||||
const envVars: NodeJS.ProcessEnv = {
|
const envVars: NodeJS.ProcessEnv = {
|
||||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface EnvSettings {
|
|||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: string;
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeCodexSettings(settingsPath: string, env: EnvSettings): void {
|
function writeSettings(settingsPath: string, env: EnvSettings): void {
|
||||||
fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2));
|
fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rewrites local root URL to provider endpoint', () => {
|
it('rewrites local root URL to provider endpoint', () => {
|
||||||
writeCodexSettings(settingsPath, {
|
writeSettings(settingsPath, {
|
||||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
|
||||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
||||||
@@ -45,7 +45,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rewrites wrong local provider path to the requested provider', () => {
|
it('rewrites wrong local provider path to the requested provider', () => {
|
||||||
writeCodexSettings(settingsPath, {
|
writeSettings(settingsPath, {
|
||||||
ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/my-codex-variant?debug=1',
|
ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/my-codex-variant?debug=1',
|
||||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
||||||
@@ -59,7 +59,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('does not rewrite localhost URLs targeting non-cliproxy ports', () => {
|
it('does not rewrite localhost URLs targeting non-cliproxy ports', () => {
|
||||||
writeCodexSettings(settingsPath, {
|
writeSettings(settingsPath, {
|
||||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
|
||||||
@@ -71,4 +71,21 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
|
|||||||
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
|
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
|
||||||
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:11434');
|
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:11434');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes dotted Claude major.minor IDs for agy provider settings', () => {
|
||||||
|
writeSettings(settingsPath, {
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
|
ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.6-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5',
|
||||||
|
});
|
||||||
|
|
||||||
|
const env = getEffectiveEnvVars('agy', 8317, settingsPath);
|
||||||
|
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6-thinking');
|
||||||
|
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||||
|
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6-thinking');
|
||||||
|
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
import { findModel, supportsThinking } from '../../../src/cliproxy/model-catalog';
|
||||||
|
|
||||||
|
describe('model-catalog compatibility lookups', () => {
|
||||||
|
it('finds agy Claude models using dotted major.minor IDs', () => {
|
||||||
|
const dottedThinking = findModel('agy', 'claude-sonnet-4.5-thinking');
|
||||||
|
const dottedNonThinking = findModel('agy', 'claude-sonnet-4.5');
|
||||||
|
|
||||||
|
expect(dottedThinking?.id).toBe('claude-sonnet-4-5-thinking');
|
||||||
|
expect(dottedNonThinking?.id).toBe('claude-sonnet-4-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports thinking checks for dotted agy model IDs', () => {
|
||||||
|
expect(supportsThinking('agy', 'claude-opus-4.6-thinking')).toBe(true);
|
||||||
|
expect(supportsThinking('agy', 'claude-sonnet-4.5')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect } from 'bun:test';
|
||||||
|
import {
|
||||||
|
extractProviderFromPathname,
|
||||||
|
isAntigravityProvider,
|
||||||
|
normalizeClaudeDottedMajorMinor,
|
||||||
|
normalizeClaudeDottedThinkingMajorMinor,
|
||||||
|
normalizeModelIdForProvider,
|
||||||
|
normalizeModelIdForRouting,
|
||||||
|
normalizeModelEnvVarsForProvider,
|
||||||
|
} from '../../../src/cliproxy/model-id-normalizer';
|
||||||
|
|
||||||
|
describe('model-id-normalizer', () => {
|
||||||
|
describe('provider parsing', () => {
|
||||||
|
it('extracts provider from provider route path', () => {
|
||||||
|
expect(extractProviderFromPathname('/api/provider/agy/v1/messages')).toBe('agy');
|
||||||
|
expect(extractProviderFromPathname('/api/provider/antigravity')).toBe('antigravity');
|
||||||
|
expect(extractProviderFromPathname('/v1/messages')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects antigravity provider aliases', () => {
|
||||||
|
expect(isAntigravityProvider('agy')).toBe(true);
|
||||||
|
expect(isAntigravityProvider('antigravity')).toBe(true);
|
||||||
|
expect(isAntigravityProvider('gemini')).toBe(false);
|
||||||
|
expect(isAntigravityProvider(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('model normalization', () => {
|
||||||
|
it('normalizes dotted Claude major.minor to hyphen format', () => {
|
||||||
|
expect(normalizeClaudeDottedMajorMinor('claude-sonnet-4.6-thinking')).toBe(
|
||||||
|
'claude-sonnet-4-6-thinking'
|
||||||
|
);
|
||||||
|
expect(normalizeClaudeDottedMajorMinor('claude-opus-4.6')).toBe('claude-opus-4-6');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes only dotted thinking variants for root/composite routing', () => {
|
||||||
|
expect(normalizeClaudeDottedThinkingMajorMinor('claude-sonnet-4.6-thinking')).toBe(
|
||||||
|
'claude-sonnet-4-6-thinking'
|
||||||
|
);
|
||||||
|
expect(normalizeClaudeDottedThinkingMajorMinor('claude-sonnet-4.6')).toBe(
|
||||||
|
'claude-sonnet-4.6'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies provider-aware routing normalization', () => {
|
||||||
|
expect(normalizeModelIdForRouting('claude-sonnet-4.6-thinking', null)).toBe(
|
||||||
|
'claude-sonnet-4-6-thinking'
|
||||||
|
);
|
||||||
|
expect(normalizeModelIdForRouting('claude-sonnet-4.6', null)).toBe('claude-sonnet-4.6');
|
||||||
|
expect(normalizeModelIdForRouting('claude-sonnet-4.6', 'agy')).toBe('claude-sonnet-4-6');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies provider-only normalization for antigravity', () => {
|
||||||
|
expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'agy')).toBe(
|
||||||
|
'claude-opus-4-6-thinking'
|
||||||
|
);
|
||||||
|
expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'gemini')).toBe(
|
||||||
|
'claude-opus-4.6-thinking'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('env normalization', () => {
|
||||||
|
it('normalizes model env vars for antigravity only', () => {
|
||||||
|
const input: NodeJS.ProcessEnv = {
|
||||||
|
ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.6-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5',
|
||||||
|
UNRELATED: 'keep-me',
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = normalizeModelEnvVarsForProvider(input, 'agy');
|
||||||
|
expect(normalized.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6-thinking');
|
||||||
|
expect(normalized.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||||
|
expect(normalized.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6');
|
||||||
|
expect(normalized.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5');
|
||||||
|
expect(normalized.UNRELATED).toBe('keep-me');
|
||||||
|
|
||||||
|
const unchanged = normalizeModelEnvVarsForProvider(input, 'gemini');
|
||||||
|
expect(unchanged.ANTHROPIC_MODEL).toBe('claude-sonnet-4.6-thinking');
|
||||||
|
expect(unchanged.UNRELATED).toBe('keep-me');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -184,6 +184,77 @@ describe('ToolSanitizationProxy Integration', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes dotted Claude thinking model IDs for root/composite routes', async () => {
|
||||||
|
const proxy = new ToolSanitizationProxy({
|
||||||
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||||
|
});
|
||||||
|
const port = await proxy.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'claude-sonnet-4.6-thinking',
|
||||||
|
messages: [{ role: 'user', content: 'test' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastRequest).not.toBeNull();
|
||||||
|
expect((lastRequest!.body as Record<string, unknown>).model).toBe(
|
||||||
|
'claude-sonnet-4-6-thinking'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
proxy.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps dotted non-thinking model IDs unchanged', async () => {
|
||||||
|
const proxy = new ToolSanitizationProxy({
|
||||||
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||||
|
});
|
||||||
|
const port = await proxy.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`http://127.0.0.1:${port}/v1/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'claude-sonnet-4.5',
|
||||||
|
messages: [{ role: 'user', content: 'test' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastRequest).not.toBeNull();
|
||||||
|
expect((lastRequest!.body as Record<string, unknown>).model).toBe('claude-sonnet-4.5');
|
||||||
|
} finally {
|
||||||
|
proxy.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes dotted Claude major.minor IDs on antigravity provider route', async () => {
|
||||||
|
const proxy = new ToolSanitizationProxy({
|
||||||
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||||
|
});
|
||||||
|
const port = await proxy.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`http://127.0.0.1:${port}/api/provider/agy/v1/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'claude-opus-4.6',
|
||||||
|
messages: [{ role: 'user', content: 'test' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastRequest).not.toBeNull();
|
||||||
|
expect((lastRequest!.body as Record<string, unknown>).model).toBe('claude-opus-4-6');
|
||||||
|
} finally {
|
||||||
|
proxy.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves other tool properties during sanitization', async () => {
|
it('preserves other tool properties during sanitization', async () => {
|
||||||
const proxy = new ToolSanitizationProxy({
|
const proxy = new ToolSanitizationProxy({
|
||||||
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`,
|
||||||
|
|||||||
Reference in new issue
Block a user