-
Notifications
You must be signed in to change notification settings - Fork 483
feat: Claude Compatible Providers System #628
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
- Rename ClaudeApiProfile to ClaudeCompatibleProvider with models[] array - Each ProviderModel has mapsToClaudeModel field for Claude tier mapping - Add providerType field for provider-specific icons (glm, minimax, openrouter) - Add thinking level support for provider models in phase selectors - Show all mapped Claude models per provider model (e.g., "Maps to Haiku, Sonnet, Opus") - Add Bulk Replace feature to switch all phases to a provider at once - Hide Bulk Replace button when no providers are enabled - Fix project-level phaseModelOverrides not persisting after refresh - Fix deleting last provider not persisting (remove empty array guard) - Add getProviderByModelId() helper for all SDK routes - Update all routes to pass provider config for provider models - Update terminology from "profiles" to "providers" throughout UI - Update documentation to reflect new provider system
Resolved conflict in use-project-settings-loader.ts: - Keep React Query approach from upstream - Add phaseModelOverrides loading for provider model persistence - Update both currentProject and projects array to keep in sync
1. AtomicWriter Race Condition Fix (libs/utils/src/atomic-writer.ts):
- Changed temp file naming from Date.now() to Date.now() + random hex
- Uses crypto.randomBytes(4).toString('hex') for uniqueness
- Prevents ENOENT errors when multiple concurrent writes happen
within the same millisecond
2. Bulk Replace "Anthropic Direct" Reset (both dialogs):
- When selecting "Anthropic Direct", now uses DEFAULT_PHASE_MODELS
- Properly resets thinking levels and other settings to defaults
- Added thinkingLevel to the change detection comparison
- Affects both global and project-level bulk replace dialogs
📝 WalkthroughWalkthroughThis PR introduces a unified Claude-compatible provider system replacing legacy API profiles. It adds server-side provider resolution helpers, per-project phase model overrides, provider-aware credential handling, and comprehensive UI for managing multiple providers (Anthropic, GLM, MiniMax, OpenRouter) with model mappings and bulk configuration tools. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant UI as UI (Route Handler)
participant SettingsService
participant Helpers as Provider Helpers
participant ProviderSDK as Claude Provider SDK
participant ClaudeAPI as Claude API
User->>UI: Request with model selection
UI->>SettingsService: getPhaseModelWithOverrides(phase)
SettingsService->>Helpers: Resolve phase model + overrides
Helpers->>SettingsService: Return { phaseModel, provider, credentials }
SettingsService-->>UI: { phaseModel, provider, credentials }
alt Provider Available
UI->>Helpers: getProviderByModelId(modelId)
Helpers->>SettingsService: Lookup provider + mappings
Helpers-->>UI: { provider, resolvedModel, credentials }
UI->>ProviderSDK: execute({ claudeCompatibleProvider, credentials })
ProviderSDK->>ClaudeAPI: POST /messages (provider endpoint)
ClaudeAPI-->>ProviderSDK: Response
else Direct API (No Provider)
UI->>ProviderSDK: execute({ credentials })
ProviderSDK->>ClaudeAPI: POST /messages (Anthropic endpoint)
ClaudeAPI-->>ProviderSDK: Response
end
ProviderSDK-->>UI: Result
UI-->>User: Response
sequenceDiagram
actor Admin as Admin / User
participant UI as UI (Settings)
participant AppStore as App Store
participant SettingsService as Backend Settings Service
participant Storage as Storage / DB
Admin->>UI: Add / Edit Claude-compatible Provider
UI->>AppStore: addClaudeCompatibleProvider(provider)
AppStore->>SettingsService: addClaudeCompatibleProvider(provider)
SettingsService->>Storage: Save updated claudeCompatibleProviders[]
Storage-->>SettingsService: Persisted
SettingsService-->>AppStore: Acknowledged
AppStore->>UI: Update state (refresh provider list)
UI-->>Admin: Provider added/saved
Admin->>UI: Select provider in model dropdown
UI->>AppStore: getClaudeCompatibleProviders()
AppStore-->>UI: providers[]
UI->>UI: Render provider models + mappings
Admin->>UI: Select provider model for phase
UI->>AppStore: setProjectPhaseModelOverride(projectId, phase, entry)
AppStore->>SettingsService: updateProject({phaseModelOverrides})
SettingsService->>Storage: Save project overrides
Storage-->>SettingsService: Persisted
SettingsService-->>AppStore: Acknowledged
AppStore->>UI: Update state
UI-->>Admin: Override applied
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes The changes introduce a substantial new provider system spanning 20+ files across types, server services, routes, and UI components. While individual patterns are relatively straightforward (provider resolution, credentials propagation, state management), the heterogeneity of changes—spanning settings migration, complex UI component logic, and multiple service integrations—combined with the breadth of affected modules requires careful verification of consistency across all integration points and end-to-end provider resolution flows. Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary of ChangesHello @stefandevo, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly enhances the application's flexibility by introducing a robust system for integrating third-party AI model providers that adhere to the Claude API protocol. This allows for greater choice in model selection, enabling users to optimize for cost, performance, or specific model capabilities across various tasks and projects. The changes span across core logic, API routes, and the user interface to seamlessly incorporate these new provider options and management capabilities. Highlights
🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code Review
This is an impressive and extensive pull request that introduces the Claude Compatible Providers system. The refactoring is well-executed across both the server and UI, allowing for flexible configuration of alternative API endpoints. The changes are consistent, and backward compatibility is thoughtfully handled with a settings migration from v5 to v6. The new server-side helpers for provider and model resolution are well-designed, and the UI enhancements, including the updated model selector and new bulk replace dialogs, are comprehensive. Additionally, the bug fixes for the AtomicWriter race condition and provider deletion persistence are solid improvements. I have one minor suggestion to improve code clarity by importing a type at the top level, but overall, this is an excellent and well-thought-out contribution.
| McpServerConfig, | ||
| PromptCustomization, | ||
| ClaudeApiProfile, | ||
| ClaudeCompatibleProvider, | ||
| PhaseModelKey, | ||
| PhaseModelEntry, | ||
| Credentials, | ||
| } from '@automaker/types'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For better code clarity and maintainability, it's good practice to import all necessary types at the top of the file. The ProviderModel type is used in getProviderByModelId and getAllProviderModels but is not imported here. Adding it to the import statement would make the code cleaner by avoiding inline import(...) statements later in the file.
McpServerConfig,
PromptCustomization,
ClaudeApiProfile,
ClaudeCompatibleProvider,
PhaseModelKey,
PhaseModelEntry,
Credentials,
ProviderModel,
} from '@automaker/types';|
Closing to recreate as cross-repository PR from fork |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
apps/server/src/services/agent-service.ts (1)
278-337: Check provider model vision support using capabilities from configuration, not ProviderFactory.Line 190 validates vision support using
ProviderFactory.modelSupportsVision()against the requested model ID, but user-configured provider models (fromclaudeCompatibleProvidersin settings) are not registered in the built-in provider registry. Provider model capabilities should be checked from themodelConfig.capabilities.supportsVisionreturned bygetProviderByModelId()at line 287, which is called after the vision check. Move the vision validation to after provider resolution completes (line 294), or add a helper to check the provider model's capabilities before falling back toProviderFactory.modelSupportsVision()for built-in models.apps/server/src/services/auto-mode-service.ts (2)
3750-3760: Use the mapped model for revision/task/continuation calls.The initial call uses
effectiveBareModel, but the follow-up executeQuery calls still usebareModel, which bypasses provider mappings and can hit the wrong model. UseeffectiveBareModelconsistently.🐛 Suggested fix
- model: bareModel, + model: effectiveBareModel,- model: bareModel, + model: effectiveBareModel,- model: bareModel, + model: effectiveBareModel,Also applies to: 3899-3909, 3999-4009
2316-2372: Analysis path does not apply provider model mapping (mapsToClaudeModel) when provider overrides are used.The analysis code uses
getPhaseModelWithOverrides+resolvePhaseModelbut unlikerunAgent(agent-service.ts), it never callsgetProviderByModelIdto resolve the provider model to its mapped Claude model. When a provider model like "GLM-4.5-Air" is selected,resolvePhaseModelreturns it unchanged without applying themapsToClaudeModelmapping (which specifies it should resolve to 'haiku'). This means provider-specific model IDs are sent directly to the executor instead of being mapped to their corresponding Claude model tier. The analysis path should callgetProviderByModelIdto properly resolve the mapped Claude model, as agent-service does.
🤖 Fix all issues with AI agents
In `@apps/server/src/lib/settings-helpers.ts`:
- Around line 510-579: In getPhaseModelWithOverrides, when resolving provider
(function getPhaseModelWithOverrides) ensure that if the provider is not found
or provider.enabled === false you strip phaseModel.providerId and reset
phaseModel.model to a safe direct-API fallback (e.g., 'sonnet') so
provider-specific model strings or stale provider IDs are not passed to the
direct Anthropic SDK; implement this right after the provider resolution
branches (where provider is set to undefined or missing) so the returned
phaseModel contains no providerId and uses a safe default model.
In `@apps/server/src/routes/github/routes/validate-issue.ts`:
- Around line 170-191: The validation gate currently runs
isClaudeModel/isCursorModel/isCodexModel/isOpencodeModel before provider
resolution, causing provider model IDs (e.g., "GLM-4.5-Air") to be rejected;
change the flow so you call getProviderByModelId(model, settingsService, ...)
first (as in the block that sets claudeCompatibleProvider,
providerResolvedModel, credentials and effectiveModel) and then run the
validation against the resolved model (providerResolvedModel || model), and
ensure useStructuredOutput uses the Claude-compatible provider check
(claudeCompatibleProvider or isClaudeModel(resolvedModel)) rather than the
original raw model string. This ensures provider models are recognized and
structured output is correctly applied.
In
`@apps/ui/src/components/views/project-settings-view/project-models-section.tsx`:
- Around line 91-192: The getModelDisplayName function in PhaseOverrideItem
currently maps only 'haiku', 'sonnet', 'opus' and thus shows raw IDs like
'claude-sonnet'; update the modelMap inside getModelDisplayName to also include
the 'claude-haiku', 'claude-sonnet', and 'claude-opus' keys (mapping to 'Claude
Haiku', 'Claude Sonnet', 'Claude Opus' respectively) while keeping the existing
fallback to entry.model so any unknown IDs still render as-is.
In
`@apps/ui/src/components/views/settings-view/model-defaults/bulk-replace-dialog.tsx`:
- Around line 58-110: getClaudeModelAlias fails to recognize extended Claude
model IDs (like "claude-opus-4-5-20251101") because it only checks exact
strings; update getClaudeModelAlias to match prefixes instead of exact equality
(e.g., use startsWith or a regex) for the three checks (haiku, sonnet, opus) so
values like "claude-opus-..." or "opus-..." map to 'opus'; keep the existing
provider lookup for custom provider models unchanged.
In
`@apps/ui/src/components/views/settings-view/providers/claude-settings-tab/api-profiles-section.tsx`:
- Around line 209-243: The save routine always sets providerData.enabled = true
which re-enables a provider when editing; instead, preserve the existing enabled
value for edits by using the original provider's enabled flag when
editingProviderId is present. Update handleSave to set providerData.enabled to
the existing provider's enabled state (e.g., editingProvider.enabled) if
editingProviderId/editingProvider is available, otherwise default to true for
new providers; locate the providerData construction in handleSave and replace
the hardcoded true with editingProvider?.enabled ?? true (or the equivalent
lookup of the original provider by editingProviderId).
In `@apps/ui/src/hooks/use-project-settings-loader.ts`:
- Around line 98-137: The code only updates project fields when
activeClaudeApiProfileId/phaseModelOverrides are defined, so removals leave
stale values; change the logic in use-project-settings-loader.ts to detect
presence of the keys on settingsWithExtras (e.g., "'activeClaudeApiProfileId' in
settingsWithExtras" and "'phaseModelOverrides' in settingsWithExtras") instead
of checking !== undefined, update needsUpdate to consider presence + difference
(including presence meaning "should be cleared"), and build updatedProjectData
to explicitly set activeClaudeApiProfileId to null (or undefined per your model)
when the key is present but value is null, or to remove/clear
phaseModelOverrides when the key is present and value is undefined/null; then
call setCurrentProject(updatedProjectData) and useAppStore.setState({ projects:
updatedProjects }) as before so both currentProject and the projects array
reflect clears as well.
In `@docs/UNIFIED_API_KEY_PROFILES.md`:
- Around line 149-164: The docs list getPhaseModelWithOverrides as returning {
model, thinkingLevel, providerId, providerConfig, credentials } but call sites
(e.g., commit-message route) destructure values as phaseModel and provider;
reconcile by verifying the actual function signature of
getPhaseModelWithOverrides and updating the docs or the function so they match:
either change the docs to document the real return shape (e.g., { phaseModel,
provider, ... } if that is what the function returns) or modify
getPhaseModelWithOverrides to return the documented keys; search for usages of
getPhaseModelWithOverrides, confirm the de-structuring patterns, and update all
affected call sites or the helper return object (reference the
getPhaseModelWithOverrides function and the commit-message route usage) so
documentation and implementation are consistent.
🧹 Nitpick comments (6)
apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx (1)
116-128: Terminology updated correctly. Minor wording suggestion.The informational text has been successfully updated to reflect the new "Claude Compatible Providers" terminology. The guidance accurately describes the provider configuration workflow.
The sentence structure in the first paragraph (lines 118-123) is functional but slightly convoluted. Consider rephrasing for improved clarity:
✨ Optional wording improvement
<p> <span className="font-medium text-foreground/80"> Using Claude Compatible Providers? </span>{' '} - Add a provider in <span className="text-blue-500">AI Providers → Claude</span>{' '} - with{' '} + To use this key, add a provider in <span className="text-blue-500">AI Providers → Claude</span>{' '} + and select{' '} <span className="font-mono text-[10px] bg-muted/50 px-1 rounded"> credentials </span>{' '} as the API key source to use this key. + as the API key source. </p>libs/types/src/settings.ts (1)
340-356: DuplicatetemplateIdmay cause lookup issues.Both MiniMax templates (international and China) share
templateId: 'minimax'. If template lookup usestemplateIdas a key, only one will be found. Consider using distinct IDs like'minimax'and'minimax-china'.♻️ Suggested fix
{ - templateId: 'minimax', + templateId: 'minimax-china', name: 'MiniMax (China)', providerType: 'minimax', baseUrl: 'https://api.minimaxi.com/anthropic',apps/server/src/routes/context/routes/describe-image.ts (1)
296-299: Logger arguments may not concatenate as expected.The
logger.infocall uses comma-separated arguments instead of template literals. Depending on the logger implementation, this may log as separate items rather than a single concatenated message.Suggested fix for consistent logging
- logger.info( - `[${requestId}] Using model: ${model}`, - provider ? `via provider: ${provider.name}` : 'direct API' - ); + logger.info( + `[${requestId}] Using model: ${model} ${provider ? `via provider: ${provider.name}` : 'direct API'}` + );apps/ui/src/components/views/project-settings-view/project-bulk-replace-dialog.tsx (3)
100-119: Helper function defined inside component may cause unnecessary re-renders.
getClaudeModelAliasis defined inside the component and used in thepreviewuseMemo. Since it's recreated on every render, it could cause the useMemo to recompute unnecessarily, though React's dependency checking may not catch this since it's not listed in dependencies.Consider either:
- Moving this function outside the component (it only depends on
enabledProviders)- Wrapping it in
useCallbackwith[enabledProviders]dependency- Including it in the useMemo dependencies (though this would still cause recomputation)
Option: Wrap in useCallback
- // Get the Claude model alias from a PhaseModelEntry - const getClaudeModelAlias = (entry: PhaseModelEntry): ClaudeModelAlias => { + // Get the Claude model alias from a PhaseModelEntry + const getClaudeModelAlias = useCallback((entry: PhaseModelEntry): ClaudeModelAlias => { // Check if model string directly matches a Claude alias if (entry.model === 'haiku' || entry.model === 'claude-haiku') return 'haiku'; if (entry.model === 'sonnet' || entry.model === 'claude-sonnet') return 'sonnet'; if (entry.model === 'opus' || entry.model === 'claude-opus') return 'opus'; // If it's a provider model, look up the mapping if (entry.providerId) { const provider = enabledProviders.find((p) => p.id === entry.providerId); if (provider) { const model = provider.models?.find((m) => m.id === entry.model); if (model?.mapsToClaudeModel) { return model.mapsToClaudeModel; } } } // Default to sonnet return 'sonnet'; - }; + }, [enabledProviders]);
140-147: Fallback behavior may be unexpected for users.When a provider doesn't have a model mapping for a specific Claude tier (e.g., no Opus equivalent), the code falls back to the first available model (lines 141-143). This could be confusing—if a user expects Opus-tier performance but gets mapped to a random first model.
The warning UI at lines 273-290 does alert users about missing mappings, which helps. Consider whether the fallback should instead keep the current model unchanged or display a more specific warning per-phase in the preview.
199-206: Consider adding error handling for override persistence.The
handleApplyfunction applies multiple overrides but doesn't handle potential errors fromsetProjectPhaseModelOverride. If one override fails to persist, the user won't be notified and the dialog will still close.Suggested improvement with error handling
const handleApply = () => { - preview.forEach(({ phase, newEntry, isChanged }) => { - if (isChanged) { - setProjectPhaseModelOverride(project.id, phase, newEntry); - } - }); - onOpenChange(false); + try { + preview.forEach(({ phase, newEntry, isChanged }) => { + if (isChanged) { + setProjectPhaseModelOverride(project.id, phase, newEntry); + } + }); + onOpenChange(false); + } catch (error) { + console.error('Failed to apply overrides:', error); + // Optionally show toast/notification to user + } };
| export async function getPhaseModelWithOverrides( | ||
| phase: PhaseModelKey, | ||
| settingsService: SettingsService, | ||
| projectPath?: string, | ||
| logPrefix = '[SettingsHelper]' | ||
| ): Promise<PhaseModelWithOverridesResult> { | ||
| try { | ||
| const globalSettings = await settingsService.getGlobalSettings(); | ||
| const credentials = await settingsService.getCredentials(); | ||
| const globalPhaseModels = globalSettings.phaseModels || {}; | ||
|
|
||
| // Start with global phase model | ||
| let phaseModel = globalPhaseModels[phase]; | ||
| let isProjectOverride = false; | ||
|
|
||
| // Check for project override | ||
| if (projectPath) { | ||
| const projectSettings = await settingsService.getProjectSettings(projectPath); | ||
| const projectOverrides = projectSettings.phaseModelOverrides || {}; | ||
|
|
||
| if (projectOverrides[phase]) { | ||
| phaseModel = projectOverrides[phase]; | ||
| isProjectOverride = true; | ||
| logger.debug(`${logPrefix} Using project override for ${phase}`); | ||
| } | ||
| } | ||
|
|
||
| // If no phase model found, use a default | ||
| if (!phaseModel) { | ||
| phaseModel = { model: 'sonnet' }; | ||
| logger.debug(`${logPrefix} No ${phase} configured, using default: sonnet`); | ||
| } | ||
|
|
||
| // Resolve provider if providerId is set | ||
| let provider: ClaudeCompatibleProvider | undefined; | ||
| if (phaseModel.providerId) { | ||
| const providers = globalSettings.claudeCompatibleProviders || []; | ||
| provider = providers.find((p) => p.id === phaseModel.providerId); | ||
|
|
||
| if (provider) { | ||
| if (provider.enabled === false) { | ||
| logger.warn( | ||
| `${logPrefix} Provider "${provider.name}" for ${phase} is disabled, falling back to direct API` | ||
| ); | ||
| provider = undefined; | ||
| } else { | ||
| logger.debug(`${logPrefix} Using provider "${provider.name}" for ${phase}`); | ||
| } | ||
| } else { | ||
| logger.warn( | ||
| `${logPrefix} Provider ${phaseModel.providerId} not found for ${phase}, falling back to direct API` | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| phaseModel, | ||
| isProjectOverride, | ||
| provider, | ||
| credentials, | ||
| }; | ||
| } catch (error) { | ||
| logger.error(`${logPrefix} Failed to get phase model with overrides:`, error); | ||
| // Return a safe default | ||
| return { | ||
| phaseModel: { model: 'sonnet' }, | ||
| isProjectOverride: false, | ||
| provider: undefined, | ||
| credentials: undefined, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Strip providerId (or fallback) when provider is missing/disabled to avoid invalid direct calls.
resolvePhaseModel now bypasses Claude mapping when providerId is set. If the provider is missing/disabled, phaseModel still carries the provider ID and provider-specific model string, which can end up sent to the direct Anthropic SDK and fail. Consider clearing providerId and falling back to a safe default when provider resolution fails.
🐛 Proposed fix
- if (phaseModel.providerId) {
+ if (phaseModel.providerId) {
+ const fallbackPhaseModel: PhaseModelEntry = {
+ model: 'sonnet',
+ thinkingLevel: phaseModel.thinkingLevel,
+ reasoningEffort: phaseModel.reasoningEffort,
+ };
const providers = globalSettings.claudeCompatibleProviders || [];
provider = providers.find((p) => p.id === phaseModel.providerId);
if (provider) {
if (provider.enabled === false) {
logger.warn(
`${logPrefix} Provider "${provider.name}" for ${phase} is disabled, falling back to direct API`
);
- provider = undefined;
+ provider = undefined;
+ phaseModel = fallbackPhaseModel;
} else {
logger.debug(`${logPrefix} Using provider "${provider.name}" for ${phase}`);
}
} else {
logger.warn(
`${logPrefix} Provider ${phaseModel.providerId} not found for ${phase}, falling back to direct API`
);
+ phaseModel = fallbackPhaseModel;
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export async function getPhaseModelWithOverrides( | |
| phase: PhaseModelKey, | |
| settingsService: SettingsService, | |
| projectPath?: string, | |
| logPrefix = '[SettingsHelper]' | |
| ): Promise<PhaseModelWithOverridesResult> { | |
| try { | |
| const globalSettings = await settingsService.getGlobalSettings(); | |
| const credentials = await settingsService.getCredentials(); | |
| const globalPhaseModels = globalSettings.phaseModels || {}; | |
| // Start with global phase model | |
| let phaseModel = globalPhaseModels[phase]; | |
| let isProjectOverride = false; | |
| // Check for project override | |
| if (projectPath) { | |
| const projectSettings = await settingsService.getProjectSettings(projectPath); | |
| const projectOverrides = projectSettings.phaseModelOverrides || {}; | |
| if (projectOverrides[phase]) { | |
| phaseModel = projectOverrides[phase]; | |
| isProjectOverride = true; | |
| logger.debug(`${logPrefix} Using project override for ${phase}`); | |
| } | |
| } | |
| // If no phase model found, use a default | |
| if (!phaseModel) { | |
| phaseModel = { model: 'sonnet' }; | |
| logger.debug(`${logPrefix} No ${phase} configured, using default: sonnet`); | |
| } | |
| // Resolve provider if providerId is set | |
| let provider: ClaudeCompatibleProvider | undefined; | |
| if (phaseModel.providerId) { | |
| const providers = globalSettings.claudeCompatibleProviders || []; | |
| provider = providers.find((p) => p.id === phaseModel.providerId); | |
| if (provider) { | |
| if (provider.enabled === false) { | |
| logger.warn( | |
| `${logPrefix} Provider "${provider.name}" for ${phase} is disabled, falling back to direct API` | |
| ); | |
| provider = undefined; | |
| } else { | |
| logger.debug(`${logPrefix} Using provider "${provider.name}" for ${phase}`); | |
| } | |
| } else { | |
| logger.warn( | |
| `${logPrefix} Provider ${phaseModel.providerId} not found for ${phase}, falling back to direct API` | |
| ); | |
| } | |
| } | |
| return { | |
| phaseModel, | |
| isProjectOverride, | |
| provider, | |
| credentials, | |
| }; | |
| } catch (error) { | |
| logger.error(`${logPrefix} Failed to get phase model with overrides:`, error); | |
| // Return a safe default | |
| return { | |
| phaseModel: { model: 'sonnet' }, | |
| isProjectOverride: false, | |
| provider: undefined, | |
| credentials: undefined, | |
| }; | |
| export async function getPhaseModelWithOverrides( | |
| phase: PhaseModelKey, | |
| settingsService: SettingsService, | |
| projectPath?: string, | |
| logPrefix = '[SettingsHelper]' | |
| ): Promise<PhaseModelWithOverridesResult> { | |
| try { | |
| const globalSettings = await settingsService.getGlobalSettings(); | |
| const credentials = await settingsService.getCredentials(); | |
| const globalPhaseModels = globalSettings.phaseModels || {}; | |
| // Start with global phase model | |
| let phaseModel = globalPhaseModels[phase]; | |
| let isProjectOverride = false; | |
| // Check for project override | |
| if (projectPath) { | |
| const projectSettings = await settingsService.getProjectSettings(projectPath); | |
| const projectOverrides = projectSettings.phaseModelOverrides || {}; | |
| if (projectOverrides[phase]) { | |
| phaseModel = projectOverrides[phase]; | |
| isProjectOverride = true; | |
| logger.debug(`${logPrefix} Using project override for ${phase}`); | |
| } | |
| } | |
| // If no phase model found, use a default | |
| if (!phaseModel) { | |
| phaseModel = { model: 'sonnet' }; | |
| logger.debug(`${logPrefix} No ${phase} configured, using default: sonnet`); | |
| } | |
| // Resolve provider if providerId is set | |
| let provider: ClaudeCompatibleProvider | undefined; | |
| if (phaseModel.providerId) { | |
| const fallbackPhaseModel: PhaseModelEntry = { | |
| model: 'sonnet', | |
| thinkingLevel: phaseModel.thinkingLevel, | |
| reasoningEffort: phaseModel.reasoningEffort, | |
| }; | |
| const providers = globalSettings.claudeCompatibleProviders || []; | |
| provider = providers.find((p) => p.id === phaseModel.providerId); | |
| if (provider) { | |
| if (provider.enabled === false) { | |
| logger.warn( | |
| `${logPrefix} Provider "${provider.name}" for ${phase} is disabled, falling back to direct API` | |
| ); | |
| provider = undefined; | |
| phaseModel = fallbackPhaseModel; | |
| } else { | |
| logger.debug(`${logPrefix} Using provider "${provider.name}" for ${phase}`); | |
| } | |
| } else { | |
| logger.warn( | |
| `${logPrefix} Provider ${phaseModel.providerId} not found for ${phase}, falling back to direct API` | |
| ); | |
| phaseModel = fallbackPhaseModel; | |
| } | |
| } | |
| return { | |
| phaseModel, | |
| isProjectOverride, | |
| provider, | |
| credentials, | |
| }; | |
| } catch (error) { | |
| logger.error(`${logPrefix} Failed to get phase model with overrides:`, error); | |
| // Return a safe default | |
| return { | |
| phaseModel: { model: 'sonnet' }, | |
| isProjectOverride: false, | |
| provider: undefined, | |
| credentials: undefined, | |
| }; | |
| } | |
| } |
🤖 Prompt for AI Agents
In `@apps/server/src/lib/settings-helpers.ts` around lines 510 - 579, In
getPhaseModelWithOverrides, when resolving provider (function
getPhaseModelWithOverrides) ensure that if the provider is not found or
provider.enabled === false you strip phaseModel.providerId and reset
phaseModel.model to a safe direct-API fallback (e.g., 'sonnet') so
provider-specific model strings or stale provider IDs are not passed to the
direct Anthropic SDK; implement this right after the provider resolution
branches (where provider is set to undefined or missing) so the returned
phaseModel contains no providerId and uses a safe default model.
| // Check if the model is a provider model (like "GLM-4.5-Air") | ||
| // If so, get the provider config and resolved Claude model | ||
| let claudeCompatibleProvider: import('@automaker/types').ClaudeCompatibleProvider | undefined; | ||
| let providerResolvedModel: string | undefined; | ||
| let credentials = await settingsService?.getCredentials(); | ||
|
|
||
| if (settingsService) { | ||
| const providerResult = await getProviderByModelId(model, settingsService, '[ValidateIssue]'); | ||
| if (providerResult.provider) { | ||
| claudeCompatibleProvider = providerResult.provider; | ||
| providerResolvedModel = providerResult.resolvedModel; | ||
| credentials = providerResult.credentials; | ||
| logger.info( | ||
| `Using provider "${providerResult.provider.name}" for model "${model}"` + | ||
| (providerResolvedModel ? ` -> resolved to "${providerResolvedModel}"` : '') | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| // Get active Claude API profile for alternative endpoint configuration | ||
| const { profile: claudeApiProfile, credentials } = await getActiveClaudeApiProfile( | ||
| settingsService, | ||
| '[IssueValidation]', | ||
| projectPath | ||
| ); | ||
| // Use provider resolved model if available, otherwise use original model | ||
| const effectiveModel = providerResolvedModel || (model as string); | ||
| logger.info(`Using model: ${effectiveModel}`); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Inspect model validation helpers and ModelId typing for provider model support
rg -n "isClaudeModel|isCodexModel|isCursorModel|isOpencodeModel" -g '*.{ts,tsx,js,jsx}'
rg -n "type ModelId|export type ModelId" -g '*.{ts,tsx,js,jsx}'Repository: AutoMaker-Org/automaker
Length of output: 5492
🏁 Script executed:
#!/bin/bash
# Check isClaudeModel implementation
head -80 libs/types/src/provider-utils.ts | tail -40
# Find useStructuredOutput in validate-issue.ts and check ordering
sed -n '120,200p' apps/server/src/routes/github/routes/validate-issue.tsRepository: AutoMaker-Org/automaker
Length of output: 4636
🏁 Script executed:
#!/bin/bash
# See full streamingQuery call with useStructuredOutput
sed -n '200,240p' apps/server/src/routes/github/routes/validate-issue.ts
# Check if useStructuredOutput is used elsewhere in the file
grep -n "useStructuredOutput" apps/server/src/routes/github/routes/validate-issue.tsRepository: AutoMaker-Org/automaker
Length of output: 1872
🏁 Script executed:
#!/bin/bash
# Check the validation at lines 358-361 and surrounding context
sed -n '350,370p' apps/server/src/routes/github/routes/validate-issue.ts
# Check if there's validation that rejects the original model before provider resolution
sed -n '1,130p' apps/server/src/routes/github/routes/validate-issue.ts | tail -40Repository: AutoMaker-Org/automaker
Length of output: 1980
🏁 Script executed:
#!/bin/bash
# Find getProviderByModelId to understand its contract
rg -n "export.*getProviderByModelId|function getProviderByModelId" -g '*.{ts,tsx,js,jsx}'
# Check if provider models should be recognized differently
rg -n "getProviderByModelId" -B 2 -A 2 apps/server/src/routes/github/routes/validate-issue.ts | head -30Repository: AutoMaker-Org/automaker
Length of output: 549
🏁 Script executed:
#!/bin/bash
# Check getProviderByModelId implementation
sed -n '608,680p' apps/lib/settings-helpers.tsRepository: AutoMaker-Org/automaker
Length of output: 138
🏁 Script executed:
#!/bin/bash
# Find the correct path for settings-helpers
find . -name "settings-helpers.ts" -type f
# Get the getProviderByModelId implementation
rg -n "export async function getProviderByModelId" -A 50 -g '*.{ts,tsx,js,jsx}'Repository: AutoMaker-Org/automaker
Length of output: 4244
🏁 Script executed:
#!/bin/bash
# Check the exact context - is line 358-361 in route handler or function?
sed -n '340,375p' apps/server/src/routes/github/routes/validate-issue.tsRepository: AutoMaker-Org/automaker
Length of output: 1266
🏁 Script executed:
#!/bin/bash
# Confirm: are provider models supposed to be valid?
# Check if provider models should pass validation or if they need special handling
rg -n "claudeCompatibleProviders|ClaudeCompatibleProvider" -A 3 -B 1 libs/types/src/index.ts | head -30
# Check settings type to understand provider model structure
rg -n "type.*ClaudeCompatibleProvider|interface.*ClaudeCompatibleProvider" -A 10 -g '*.{ts,tsx}'Repository: AutoMaker-Org/automaker
Length of output: 10840
🏁 Script executed:
#!/bin/bash
# Check how other endpoints handle provider model validation
# Look at enhance.ts to see if it has the same validation pattern
sed -n '70,130p' apps/server/src/routes/enhance-prompt/routes/enhance.tsRepository: AutoMaker-Org/automaker
Length of output: 2572
🏁 Script executed:
#!/bin/bash
# Check if enhance.ts has model validation like validate-issue.ts does
rg -n "isClaudeModel|isCursorModel|isCodexModel|isOpencodeModel" apps/server/src/routes/enhance-prompt/routes/enhance.ts | head -20Repository: AutoMaker-Org/automaker
Length of output: 49
🏁 Script executed:
#!/bin/bash
# Check the complete picture - confirm the validation order
# Show the full route handler structure to see validation vs handler function call
sed -n '330,410p' apps/server/src/routes/github/routes/validate-issue.tsRepository: AutoMaker-Org/automaker
Length of output: 2539
Fix validation gate to recognize provider models before rejecting them.
Provider model IDs (like "GLM-4.5-Air") fail validation at lines 358–369 and return 400 immediately, never reaching the provider resolution code at lines 170–191. The validation checks isClaudeModel(model) || isCursorModel(model) || isCodexModel(model) || isOpencodeModel(model), but provider models don't match these checks since they contain no "claude-" string and aren't recognized aliases.
Resolve the provider model before validation, or validate provider models via getProviderByModelId first, then validate the resolved model. This also prevents the secondary issue where useStructuredOutput (line 128) would skip JSON schema output even for Claude-compatible providers.
🤖 Prompt for AI Agents
In `@apps/server/src/routes/github/routes/validate-issue.ts` around lines 170 -
191, The validation gate currently runs
isClaudeModel/isCursorModel/isCodexModel/isOpencodeModel before provider
resolution, causing provider model IDs (e.g., "GLM-4.5-Air") to be rejected;
change the flow so you call getProviderByModelId(model, settingsService, ...)
first (as in the block that sets claudeCompatibleProvider,
providerResolvedModel, credentials and effectiveModel) and then run the
validation against the resolved model (providerResolvedModel || model), and
ensure useStructuredOutput uses the Claude-compatible provider check
(claudeCompatibleProvider or isClaudeModel(resolvedModel)) rather than the
original raw model string. This ensures provider models are recognized and
structured output is correctly applied.
| function PhaseOverrideItem({ | ||
| phase, | ||
| project, | ||
| globalValue, | ||
| projectOverride, | ||
| }: { | ||
| phase: PhaseConfig; | ||
| project: Project; | ||
| globalValue: PhaseModelEntry; | ||
| projectOverride?: PhaseModelEntry; | ||
| }) { | ||
| const { setProjectPhaseModelOverride, claudeCompatibleProviders } = useAppStore(); | ||
|
|
||
| const hasOverride = !!projectOverride; | ||
| const effectiveValue = projectOverride || globalValue; | ||
|
|
||
| // Get display name for a model | ||
| const getModelDisplayName = (entry: PhaseModelEntry): string => { | ||
| if (entry.providerId) { | ||
| const provider = (claudeCompatibleProviders || []).find((p) => p.id === entry.providerId); | ||
| if (provider) { | ||
| const model = provider.models?.find((m) => m.id === entry.model); | ||
| if (model) { | ||
| return `${model.displayName} (${provider.name})`; | ||
| } | ||
| } | ||
| } | ||
| // Default to model ID for built-in models | ||
| const modelMap: Record<string, string> = { | ||
| haiku: 'Claude Haiku', | ||
| sonnet: 'Claude Sonnet', | ||
| opus: 'Claude Opus', | ||
| }; | ||
| return modelMap[entry.model] || entry.model; | ||
| }; | ||
|
|
||
| const handleClearOverride = () => { | ||
| setProjectPhaseModelOverride(project.id, phase.key, null); | ||
| }; | ||
|
|
||
| const handleSetOverride = (entry: PhaseModelEntry) => { | ||
| setProjectPhaseModelOverride(project.id, phase.key, entry); | ||
| }; | ||
|
|
||
| return ( | ||
| <div | ||
| className={cn( | ||
| 'flex items-center justify-between p-4 rounded-xl', | ||
| 'bg-accent/20 border', | ||
| hasOverride ? 'border-brand-500/30 bg-brand-500/5' : 'border-border/30', | ||
| 'hover:bg-accent/30 transition-colors' | ||
| )} | ||
| > | ||
| <div className="flex-1 pr-4"> | ||
| <div className="flex items-center gap-2"> | ||
| <h4 className="text-sm font-medium text-foreground">{phase.label}</h4> | ||
| {hasOverride ? ( | ||
| <span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-brand-500/20 text-brand-500"> | ||
| Override | ||
| </span> | ||
| ) : ( | ||
| <span className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded bg-muted text-muted-foreground"> | ||
| <Globe className="w-3 h-3" /> | ||
| Global | ||
| </span> | ||
| )} | ||
| </div> | ||
| <p className="text-xs text-muted-foreground">{phase.description}</p> | ||
| {hasOverride && ( | ||
| <p className="text-xs text-brand-500 mt-1"> | ||
| Using: {getModelDisplayName(effectiveValue)} | ||
| </p> | ||
| )} | ||
| {!hasOverride && ( | ||
| <p className="text-xs text-muted-foreground/70 mt-1"> | ||
| Using global: {getModelDisplayName(globalValue)} | ||
| </p> | ||
| )} | ||
| </div> | ||
|
|
||
| <div className="flex items-center gap-2"> | ||
| {hasOverride && ( | ||
| <Button | ||
| variant="ghost" | ||
| size="sm" | ||
| onClick={handleClearOverride} | ||
| className="h-8 px-2 text-xs text-muted-foreground hover:text-foreground" | ||
| > | ||
| <RotateCcw className="w-3.5 h-3.5 mr-1" /> | ||
| Reset | ||
| </Button> | ||
| )} | ||
| <PhaseModelSelector | ||
| compact | ||
| value={effectiveValue} | ||
| onChange={handleSetOverride} | ||
| align="end" | ||
| /> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix Claude display names for default IDs.
getModelDisplayName only maps haiku/sonnet/opus, so defaults like claude-sonnet render as raw IDs. Add claude-* keys (and keep fallback).
🩹 Proposed fix
- const modelMap: Record<string, string> = {
- haiku: 'Claude Haiku',
- sonnet: 'Claude Sonnet',
- opus: 'Claude Opus',
- };
+ const modelMap: Record<string, string> = {
+ haiku: 'Claude Haiku',
+ sonnet: 'Claude Sonnet',
+ opus: 'Claude Opus',
+ 'claude-haiku': 'Claude Haiku',
+ 'claude-sonnet': 'Claude Sonnet',
+ 'claude-opus': 'Claude Opus',
+ };🤖 Prompt for AI Agents
In
`@apps/ui/src/components/views/project-settings-view/project-models-section.tsx`
around lines 91 - 192, The getModelDisplayName function in PhaseOverrideItem
currently maps only 'haiku', 'sonnet', 'opus' and thus shows raw IDs like
'claude-sonnet'; update the modelMap inside getModelDisplayName to also include
the 'claude-haiku', 'claude-sonnet', and 'claude-opus' keys (mapping to 'Claude
Haiku', 'Claude Sonnet', 'Claude Opus' respectively) while keeping the existing
fallback to entry.model so any unknown IDs still render as-is.
| export function BulkReplaceDialog({ open, onOpenChange }: BulkReplaceDialogProps) { | ||
| const { phaseModels, setPhaseModel, claudeCompatibleProviders } = useAppStore(); | ||
| const [selectedProvider, setSelectedProvider] = useState<string>('anthropic'); | ||
|
|
||
| // Get enabled providers | ||
| const enabledProviders = useMemo(() => { | ||
| return (claudeCompatibleProviders || []).filter((p) => p.enabled !== false); | ||
| }, [claudeCompatibleProviders]); | ||
|
|
||
| // Build provider options for the dropdown | ||
| const providerOptions = useMemo(() => { | ||
| const options: Array<{ id: string; name: string; isNative: boolean }> = [ | ||
| { id: 'anthropic', name: 'Anthropic Direct', isNative: true }, | ||
| ]; | ||
|
|
||
| enabledProviders.forEach((provider) => { | ||
| options.push({ | ||
| id: provider.id, | ||
| name: provider.name, | ||
| isNative: false, | ||
| }); | ||
| }); | ||
|
|
||
| return options; | ||
| }, [enabledProviders]); | ||
|
|
||
| // Get the selected provider config (if custom) | ||
| const selectedProviderConfig = useMemo(() => { | ||
| if (selectedProvider === 'anthropic') return null; | ||
| return enabledProviders.find((p) => p.id === selectedProvider); | ||
| }, [selectedProvider, enabledProviders]); | ||
|
|
||
| // Get the Claude model alias from a PhaseModelEntry | ||
| const getClaudeModelAlias = (entry: PhaseModelEntry): ClaudeModelAlias => { | ||
| // Check if model string directly matches a Claude alias | ||
| if (entry.model === 'haiku' || entry.model === 'claude-haiku') return 'haiku'; | ||
| if (entry.model === 'sonnet' || entry.model === 'claude-sonnet') return 'sonnet'; | ||
| if (entry.model === 'opus' || entry.model === 'claude-opus') return 'opus'; | ||
|
|
||
| // If it's a provider model, look up the mapping | ||
| if (entry.providerId) { | ||
| const provider = enabledProviders.find((p) => p.id === entry.providerId); | ||
| if (provider) { | ||
| const model = provider.models?.find((m) => m.id === entry.model); | ||
| if (model?.mapsToClaudeModel) { | ||
| return model.mapsToClaudeModel; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Default to sonnet | ||
| return 'sonnet'; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix Claude tier detection for full model IDs.
getClaudeModelAlias only matches exact claude-haiku/sonnet/opus. If a phase is configured with full model IDs (e.g., claude-opus-4-5-20251101), the code defaults to sonnet, causing incorrect bulk mapping.
🩹 Proposed fix
- const getClaudeModelAlias = (entry: PhaseModelEntry): ClaudeModelAlias => {
- // Check if model string directly matches a Claude alias
- if (entry.model === 'haiku' || entry.model === 'claude-haiku') return 'haiku';
- if (entry.model === 'sonnet' || entry.model === 'claude-sonnet') return 'sonnet';
- if (entry.model === 'opus' || entry.model === 'claude-opus') return 'opus';
+ const getClaudeModelAlias = (entry: PhaseModelEntry): ClaudeModelAlias => {
+ const modelId = entry.model.toLowerCase();
+ // Check if model string directly matches a Claude alias or full model id
+ if (modelId === 'haiku' || modelId.startsWith('claude-haiku')) return 'haiku';
+ if (modelId === 'sonnet' || modelId.startsWith('claude-sonnet')) return 'sonnet';
+ if (modelId === 'opus' || modelId.startsWith('claude-opus')) return 'opus';🤖 Prompt for AI Agents
In
`@apps/ui/src/components/views/settings-view/model-defaults/bulk-replace-dialog.tsx`
around lines 58 - 110, getClaudeModelAlias fails to recognize extended Claude
model IDs (like "claude-opus-4-5-20251101") because it only checks exact
strings; update getClaudeModelAlias to match prefixes instead of exact equality
(e.g., use startsWith or a regex) for the three checks (haiku, sonnet, opus) so
values like "claude-opus-..." or "opus-..." map to 'opus'; keep the existing
provider lookup for custom provider models unchanged.
| const handleSave = () => { | ||
| const profileData: ClaudeApiProfile = { | ||
| id: editingProfileId ?? generateProfileId(), | ||
| // For GLM/MiniMax, enforce fixed settings | ||
| const isFixedProvider = hasFixedSettings(formData.providerType); | ||
|
|
||
| // Convert form models to ProviderModel format | ||
| const models: ProviderModel[] = formData.models | ||
| .filter((m) => m.id.trim()) // Only include models with IDs | ||
| .map((m) => ({ | ||
| id: m.id.trim(), | ||
| displayName: m.displayName.trim() || m.id.trim(), | ||
| mapsToClaudeModel: m.mapsToClaudeModel, | ||
| })); | ||
|
|
||
| const providerData: ClaudeCompatibleProvider = { | ||
| id: editingProviderId ?? generateProviderId(), | ||
| name: formData.name.trim(), | ||
| providerType: formData.providerType, | ||
| enabled: true, | ||
| baseUrl: formData.baseUrl.trim(), | ||
| apiKeySource: formData.apiKeySource, | ||
| // For fixed providers, always use inline | ||
| apiKeySource: isFixedProvider ? 'inline' : formData.apiKeySource, | ||
| // Only include apiKey when source is 'inline' | ||
| apiKey: formData.apiKeySource === 'inline' ? formData.apiKey : undefined, | ||
| useAuthToken: formData.useAuthToken, | ||
| apiKey: isFixedProvider || formData.apiKeySource === 'inline' ? formData.apiKey : undefined, | ||
| // For fixed providers, always use auth token | ||
| useAuthToken: isFixedProvider ? true : formData.useAuthToken, | ||
| timeoutMs: (() => { | ||
| const parsed = Number(formData.timeoutMs); | ||
| return Number.isFinite(parsed) ? parsed : undefined; | ||
| })(), | ||
| modelMappings: | ||
| formData.modelMappings.haiku || formData.modelMappings.sonnet || formData.modelMappings.opus | ||
| ? { | ||
| ...(formData.modelMappings.haiku && { haiku: formData.modelMappings.haiku }), | ||
| ...(formData.modelMappings.sonnet && { sonnet: formData.modelMappings.sonnet }), | ||
| ...(formData.modelMappings.opus && { opus: formData.modelMappings.opus }), | ||
| } | ||
| : undefined, | ||
| disableNonessentialTraffic: formData.disableNonessentialTraffic || undefined, | ||
| models, | ||
| // For fixed providers, always disable non-essential | ||
| disableNonessentialTraffic: isFixedProvider | ||
| ? true | ||
| : formData.disableNonessentialTraffic || undefined, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Preserve provider enabled state on edit.
enabled: true is always set on save, so editing a disabled provider re-enables it unintentionally. Preserve the prior enabled state when editing.
🐛 Suggested fix
- const providerData: ClaudeCompatibleProvider = {
- id: editingProviderId ?? generateProviderId(),
- name: formData.name.trim(),
- providerType: formData.providerType,
- enabled: true,
+ const existingProvider = editingProviderId
+ ? claudeCompatibleProviders.find((p) => p.id === editingProviderId)
+ : undefined;
+ const providerData: ClaudeCompatibleProvider = {
+ id: editingProviderId ?? generateProviderId(),
+ name: formData.name.trim(),
+ providerType: formData.providerType,
+ enabled: existingProvider?.enabled ?? true,📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleSave = () => { | |
| const profileData: ClaudeApiProfile = { | |
| id: editingProfileId ?? generateProfileId(), | |
| // For GLM/MiniMax, enforce fixed settings | |
| const isFixedProvider = hasFixedSettings(formData.providerType); | |
| // Convert form models to ProviderModel format | |
| const models: ProviderModel[] = formData.models | |
| .filter((m) => m.id.trim()) // Only include models with IDs | |
| .map((m) => ({ | |
| id: m.id.trim(), | |
| displayName: m.displayName.trim() || m.id.trim(), | |
| mapsToClaudeModel: m.mapsToClaudeModel, | |
| })); | |
| const providerData: ClaudeCompatibleProvider = { | |
| id: editingProviderId ?? generateProviderId(), | |
| name: formData.name.trim(), | |
| providerType: formData.providerType, | |
| enabled: true, | |
| baseUrl: formData.baseUrl.trim(), | |
| apiKeySource: formData.apiKeySource, | |
| // For fixed providers, always use inline | |
| apiKeySource: isFixedProvider ? 'inline' : formData.apiKeySource, | |
| // Only include apiKey when source is 'inline' | |
| apiKey: formData.apiKeySource === 'inline' ? formData.apiKey : undefined, | |
| useAuthToken: formData.useAuthToken, | |
| apiKey: isFixedProvider || formData.apiKeySource === 'inline' ? formData.apiKey : undefined, | |
| // For fixed providers, always use auth token | |
| useAuthToken: isFixedProvider ? true : formData.useAuthToken, | |
| timeoutMs: (() => { | |
| const parsed = Number(formData.timeoutMs); | |
| return Number.isFinite(parsed) ? parsed : undefined; | |
| })(), | |
| modelMappings: | |
| formData.modelMappings.haiku || formData.modelMappings.sonnet || formData.modelMappings.opus | |
| ? { | |
| ...(formData.modelMappings.haiku && { haiku: formData.modelMappings.haiku }), | |
| ...(formData.modelMappings.sonnet && { sonnet: formData.modelMappings.sonnet }), | |
| ...(formData.modelMappings.opus && { opus: formData.modelMappings.opus }), | |
| } | |
| : undefined, | |
| disableNonessentialTraffic: formData.disableNonessentialTraffic || undefined, | |
| models, | |
| // For fixed providers, always disable non-essential | |
| disableNonessentialTraffic: isFixedProvider | |
| ? true | |
| : formData.disableNonessentialTraffic || undefined, | |
| }; | |
| const handleSave = () => { | |
| // For GLM/MiniMax, enforce fixed settings | |
| const isFixedProvider = hasFixedSettings(formData.providerType); | |
| // Convert form models to ProviderModel format | |
| const models: ProviderModel[] = formData.models | |
| .filter((m) => m.id.trim()) // Only include models with IDs | |
| .map((m) => ({ | |
| id: m.id.trim(), | |
| displayName: m.displayName.trim() || m.id.trim(), | |
| mapsToClaudeModel: m.mapsToClaudeModel, | |
| })); | |
| const existingProvider = editingProviderId | |
| ? claudeCompatibleProviders.find((p) => p.id === editingProviderId) | |
| : undefined; | |
| const providerData: ClaudeCompatibleProvider = { | |
| id: editingProviderId ?? generateProviderId(), | |
| name: formData.name.trim(), | |
| providerType: formData.providerType, | |
| enabled: existingProvider?.enabled ?? true, | |
| baseUrl: formData.baseUrl.trim(), | |
| // For fixed providers, always use inline | |
| apiKeySource: isFixedProvider ? 'inline' : formData.apiKeySource, | |
| // Only include apiKey when source is 'inline' | |
| apiKey: isFixedProvider || formData.apiKeySource === 'inline' ? formData.apiKey : undefined, | |
| // For fixed providers, always use auth token | |
| useAuthToken: isFixedProvider ? true : formData.useAuthToken, | |
| timeoutMs: (() => { | |
| const parsed = Number(formData.timeoutMs); | |
| return Number.isFinite(parsed) ? parsed : undefined; | |
| })(), | |
| models, | |
| // For fixed providers, always disable non-essential | |
| disableNonessentialTraffic: isFixedProvider | |
| ? true | |
| : formData.disableNonessentialTraffic || undefined, | |
| }; |
🤖 Prompt for AI Agents
In
`@apps/ui/src/components/views/settings-view/providers/claude-settings-tab/api-profiles-section.tsx`
around lines 209 - 243, The save routine always sets providerData.enabled = true
which re-enables a provider when editing; instead, preserve the existing enabled
value for edits by using the original provider's enabled flag when
editingProviderId is present. Update handleSave to set providerData.enabled to
the existing provider's enabled state (e.g., editingProvider.enabled) if
editingProviderId/editingProvider is available, otherwise default to true for
new providers; locate the providerData construction in handleSave and replace
the hardcoded true with editingProvider?.enabled ?? true (or the equivalent
lookup of the original provider by editingProviderId).
| // Apply activeClaudeApiProfileId and phaseModelOverrides if present | ||
| // These are stored directly on the project, so we need to update both | ||
| // currentProject AND the projects array to keep them in sync | ||
| // Type assertion needed because API returns Record<string, unknown> | ||
| const settingsWithExtras = settings as Record<string, unknown>; | ||
| const activeClaudeApiProfileId = settingsWithExtras.activeClaudeApiProfileId as | ||
| | string | ||
| | null | ||
| | undefined; | ||
| const phaseModelOverrides = settingsWithExtras.phaseModelOverrides as | ||
| | import('@automaker/types').PhaseModelConfig | ||
| | undefined; | ||
|
|
||
| // Check if we need to update the project | ||
| const storeState = useAppStore.getState(); | ||
| const updatedProject = storeState.currentProject; | ||
| if (updatedProject && updatedProject.path === projectPath) { | ||
| const needsUpdate = | ||
| (activeClaudeApiProfileId !== undefined && | ||
| updatedProject.activeClaudeApiProfileId !== activeClaudeApiProfileId) || | ||
| (phaseModelOverrides !== undefined && | ||
| JSON.stringify(updatedProject.phaseModelOverrides) !== | ||
| JSON.stringify(phaseModelOverrides)); | ||
|
|
||
| if (needsUpdate) { | ||
| const updatedProjectData = { | ||
| ...updatedProject, | ||
| activeClaudeApiProfileId: settings.activeClaudeApiProfileId, | ||
| }); | ||
| ...(activeClaudeApiProfileId !== undefined && { activeClaudeApiProfileId }), | ||
| ...(phaseModelOverrides !== undefined && { phaseModelOverrides }), | ||
| }; | ||
|
|
||
| // Update currentProject | ||
| setCurrentProject(updatedProjectData); | ||
|
|
||
| // Also update the project in the projects array to keep them in sync | ||
| const updatedProjects = storeState.projects.map((p) => | ||
| p.id === updatedProject.id ? updatedProjectData : p | ||
| ); | ||
| useAppStore.setState({ projects: updatedProjects }); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Clear overrides when settings omit the fields to avoid stale project state.
Right now updates only occur when activeClaudeApiProfileId / phaseModelOverrides are defined. If a project removes these fields, the store retains stale overrides. Consider treating “field missing” as an explicit clear.
🛠️ Proposed fix
- const settingsWithExtras = settings as Record<string, unknown>;
- const activeClaudeApiProfileId = settingsWithExtras.activeClaudeApiProfileId as
- | string
- | null
- | undefined;
- const phaseModelOverrides = settingsWithExtras.phaseModelOverrides as
- | import('@automaker/types').PhaseModelConfig
- | undefined;
+ const settingsWithExtras = settings as Record<string, unknown>;
+ const hasActiveClaudeApiProfileId = Object.prototype.hasOwnProperty.call(
+ settingsWithExtras,
+ 'activeClaudeApiProfileId'
+ );
+ const activeClaudeApiProfileId = hasActiveClaudeApiProfileId
+ ? (settingsWithExtras.activeClaudeApiProfileId as string | null)
+ : undefined;
+ const hasPhaseModelOverrides = Object.prototype.hasOwnProperty.call(
+ settingsWithExtras,
+ 'phaseModelOverrides'
+ );
+ const phaseModelOverrides = hasPhaseModelOverrides
+ ? (settingsWithExtras.phaseModelOverrides as
+ | Partial<import('@automaker/types').PhaseModelConfig>
+ | undefined)
+ : undefined;
@@
- const needsUpdate =
- (activeClaudeApiProfileId !== undefined &&
- updatedProject.activeClaudeApiProfileId !== activeClaudeApiProfileId) ||
- (phaseModelOverrides !== undefined &&
- JSON.stringify(updatedProject.phaseModelOverrides) !==
- JSON.stringify(phaseModelOverrides));
+ const needsUpdate =
+ (hasActiveClaudeApiProfileId &&
+ updatedProject.activeClaudeApiProfileId !== activeClaudeApiProfileId) ||
+ (!hasActiveClaudeApiProfileId && updatedProject.activeClaudeApiProfileId !== undefined) ||
+ (hasPhaseModelOverrides &&
+ JSON.stringify(updatedProject.phaseModelOverrides) !==
+ JSON.stringify(phaseModelOverrides)) ||
+ (!hasPhaseModelOverrides && updatedProject.phaseModelOverrides !== undefined);
@@
- const updatedProjectData = {
+ const updatedProjectData = {
...updatedProject,
- ...(activeClaudeApiProfileId !== undefined && { activeClaudeApiProfileId }),
- ...(phaseModelOverrides !== undefined && { phaseModelOverrides }),
+ ...(hasActiveClaudeApiProfileId
+ ? { activeClaudeApiProfileId }
+ : { activeClaudeApiProfileId: undefined }),
+ ...(hasPhaseModelOverrides
+ ? { phaseModelOverrides }
+ : { phaseModelOverrides: undefined }),
};🤖 Prompt for AI Agents
In `@apps/ui/src/hooks/use-project-settings-loader.ts` around lines 98 - 137, The
code only updates project fields when
activeClaudeApiProfileId/phaseModelOverrides are defined, so removals leave
stale values; change the logic in use-project-settings-loader.ts to detect
presence of the keys on settingsWithExtras (e.g., "'activeClaudeApiProfileId' in
settingsWithExtras" and "'phaseModelOverrides' in settingsWithExtras") instead
of checking !== undefined, update needsUpdate to consider presence + difference
(including presence meaning "should be cleared"), and build updatedProjectData
to explicitly set activeClaudeApiProfileId to null (or undefined per your model)
when the key is present but value is null, or to remove/clear
phaseModelOverrides when the key is present and value is undefined/null; then
call setCurrentProject(updatedProjectData) and useAppStore.setState({ projects:
updatedProjects }) as before so both currentProject and the projects array
reflect clears as well.
| The `getPhaseModelWithOverrides()` helper gets effective phase model config: | ||
|
|
||
| Added an informational note: | ||
| ```typescript | ||
| export async function getPhaseModelWithOverrides( | ||
| phaseKey: PhaseModelKey, | ||
| settingsService: SettingsService, | ||
| projectPath?: string, | ||
| logPrefix?: string | ||
| ): Promise<{ | ||
| model: string; | ||
| thinkingLevel?: ThinkingLevel; | ||
| providerId?: string; | ||
| providerConfig?: ClaudeCompatibleProvider; | ||
| credentials?: Credentials; | ||
| }>; | ||
| ``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Validate getPhaseModelWithOverrides return shape in docs.
This section documents { model, thinkingLevel, providerId, providerConfig, credentials }, but call sites (e.g., commit-message route) destructure phaseModel and provider. Please confirm the helper’s actual signature and update this section to avoid drift.
#!/bin/bash
# Verify getPhaseModelWithOverrides signature and usage.
rg -n -C2 "getPhaseModelWithOverrides"🤖 Prompt for AI Agents
In `@docs/UNIFIED_API_KEY_PROFILES.md` around lines 149 - 164, The docs list
getPhaseModelWithOverrides as returning { model, thinkingLevel, providerId,
providerConfig, credentials } but call sites (e.g., commit-message route)
destructure values as phaseModel and provider; reconcile by verifying the actual
function signature of getPhaseModelWithOverrides and updating the docs or the
function so they match: either change the docs to document the real return shape
(e.g., { phaseModel, provider, ... } if that is what the function returns) or
modify getPhaseModelWithOverrides to return the documented keys; search for
usages of getPhaseModelWithOverrides, confirm the de-structuring patterns, and
update all affected call sites or the helper return object (reference the
getPhaseModelWithOverrides function and the commit-message route usage) so
documentation and implementation are consistent.
Summary
This PR introduces the Claude Compatible Providers system, a major enhancement that allows users to configure alternative API endpoints that expose Claude-compatible models throughout the application.
Key Features
phaseModelOverridesTechnical Changes
Types (
libs/types/src/settings.ts):ClaudeApiProfiletoClaudeCompatibleProviderProviderModeltype withmapsToClaudeModelfieldPhaseModelEntrywith optionalproviderIdfieldServer (
apps/server/src/):getProviderByModelId(),getPhaseModelWithOverrides()UI (
apps/ui/src/):providerTypefieldphaseModelOverridesBug Fixes in This PR
DEFAULT_PHASE_MODELSincluding thinking levelsTest Plan
Screenshots
Summary by CodeRabbit
New Features
UI Updates
Chores
✏️ Tip: You can customize this high-level summary in your review settings.