-
Notifications
You must be signed in to change notification settings - Fork 300
feat: inline chat model selector in composer #1822
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
base: main
Are you sure you want to change the base?
Changes from all commits
673ab1f
2f6b25d
f36f9a7
34779f9
91f50a4
4d29244
78ca407
3d4b1e8
240c521
8a6170b
07f0de2
a312415
ce8a1ba
056509a
6daf6e7
918e77e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -185,6 +185,25 @@ type GetStateOptions = { | |||||||||||||||||||||||||||||
| includeTaskHistory?: boolean | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
| * Internal options for {@link ClineProvider.upsertProviderProfile}. | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
| type UpsertProviderProfileOptions = { | ||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
| * Internal-only bypass of the organization model allow-list, used exclusively | ||||||||||||||||||||||||||||||
| * for Zoo Gateway credential synchronization (token refresh) and sign-out | ||||||||||||||||||||||||||||||
| * writes. These are auth writes, not model selections: a restrictive | ||||||||||||||||||||||||||||||
| * allow-list may omit `zoo-gateway` entirely, or list the provider without the | ||||||||||||||||||||||||||||||
| * active `zooGatewayModelId`, and must not reject the credential write and | ||||||||||||||||||||||||||||||
| * leave stale credentials behind in the active profile. | ||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||
| * This flag must never be set from a webview-originated code path: the webview | ||||||||||||||||||||||||||||||
| * is not a trusted boundary, so every user-driven profile write keeps the | ||||||||||||||||||||||||||||||
| * allow-list enforcement. | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
| bypassAllowList?: boolean | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export class ClineProvider | ||||||||||||||||||||||||||||||
| extends EventEmitter<TaskProviderEvents> | ||||||||||||||||||||||||||||||
| implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike | ||||||||||||||||||||||||||||||
|
|
@@ -1835,20 +1854,106 @@ export class ClineProvider | |||||||||||||||||||||||||||||
| name: string, | ||||||||||||||||||||||||||||||
| providerSettings: ProviderSettings, | ||||||||||||||||||||||||||||||
| activate: boolean = true, | ||||||||||||||||||||||||||||||
| options: UpsertProviderProfileOptions = {}, | ||||||||||||||||||||||||||||||
| ): Promise<string | undefined> { | ||||||||||||||||||||||||||||||
| // Enforce the organization model allow-list before persisting or | ||||||||||||||||||||||||||||||
| // activating a profile. The webview is not a trusted boundary, so the | ||||||||||||||||||||||||||||||
| // model selector's client-side gating cannot be the only check. | ||||||||||||||||||||||||||||||
| // Task creation validates too, but rejecting here prevents an | ||||||||||||||||||||||||||||||
| // unauthorized profile from being written or activated at all. | ||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||
| // `bypassAllowList` is reserved for internal Zoo Gateway credential | ||||||||||||||||||||||||||||||
| // writes (token refresh / sign-out), which are auth writes rather than | ||||||||||||||||||||||||||||||
| // model selections; see `UpsertProviderProfileOptions`. | ||||||||||||||||||||||||||||||
| if (!options.bypassAllowList) { | ||||||||||||||||||||||||||||||
| let organizationAllowList = ORGANIZATION_ALLOW_ALL | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if (CloudService.hasInstance()) { | ||||||||||||||||||||||||||||||
| // The webview is not a trusted boundary, so a profile write must not | ||||||||||||||||||||||||||||||
| // fail open. Allow-all is only legitimate when no cloud instance exists | ||||||||||||||||||||||||||||||
| // (positively no organization policy). If a cloud instance exists but its | ||||||||||||||||||||||||||||||
| // policy cannot be read, reject the write rather than persisting a | ||||||||||||||||||||||||||||||
| // possibly disallowed model during a transient cloud/allow-list failure. | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| organizationAllowList = await CloudService.instance.getAllowList() | ||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||
| this.log( | ||||||||||||||||||||||||||||||
| `[upsertProviderProfile] Blocked profile "${name}": organization allow-list unavailable: ${ | ||||||||||||||||||||||||||||||
| error instanceof Error ? error.message : String(error) | ||||||||||||||||||||||||||||||
| }`, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| void vscode.window.showErrorMessage(t("common:errors.violated_organization_allowlist")) | ||||||||||||||||||||||||||||||
| return undefined | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if (!ProfileValidator.isProfileAllowed(providerSettings, organizationAllowList)) { | ||||||||||||||||||||||||||||||
| this.log( | ||||||||||||||||||||||||||||||
| `[upsertProviderProfile] Blocked profile "${name}": model is not allowed by the organization allow-list`, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| // Surface the rejection to the user here rather than in the webview | ||||||||||||||||||||||||||||||
| // handler: direct callers (OAuth callbacks, sign-out) reach this | ||||||||||||||||||||||||||||||
| // method without the handler and would otherwise fail silently. No | ||||||||||||||||||||||||||||||
| // webview context is required, so non-webview callers are safe. | ||||||||||||||||||||||||||||||
| void vscode.window.showErrorMessage(t("common:errors.violated_organization_allowlist")) | ||||||||||||||||||||||||||||||
| return undefined | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| return await this.enqueueProviderProfileMutation(async (signal) => { | ||||||||||||||||||||||||||||||
| // TODO: Do we need to be calling `activateProfile`? It's not | ||||||||||||||||||||||||||||||
| // clear to me what the source of truth should be; in some cases | ||||||||||||||||||||||||||||||
| // we rely on the `ContextProxy`'s data store and in other cases | ||||||||||||||||||||||||||||||
| // we rely on the `ProviderSettingsManager`'s data store. It might | ||||||||||||||||||||||||||||||
| // be simpler to unify these two. | ||||||||||||||||||||||||||||||
| // Snapshot the pre-write state so a failure *after* `saveConfig` | ||||||||||||||||||||||||||||||
| // succeeds (during the activation writes below) can be rolled back | ||||||||||||||||||||||||||||||
| // instead of leaving the profile secret and the active/mode state | ||||||||||||||||||||||||||||||
| // divergent — the profile would carry the new model while the mode | ||||||||||||||||||||||||||||||
| // or current profile still pointed at the old one. | ||||||||||||||||||||||||||||||
| const priorCurrentApiConfigName = this.contextProxy.getValue("currentApiConfigName") | ||||||||||||||||||||||||||||||
| const priorProviderSettings = this.contextProxy.getProviderSettings() | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // `getProfile` wraps both "not found" and transient read failures in the | ||||||||||||||||||||||||||||||
| // same error, so it cannot decide whether the profile pre-existed. Probe | ||||||||||||||||||||||||||||||
| // existence explicitly: a destructive rollback (`deleteConfig`) must only | ||||||||||||||||||||||||||||||
| // run when absence is confirmed, never on a swallowed read error that would | ||||||||||||||||||||||||||||||
| // otherwise delete an existing profile and its secrets. | ||||||||||||||||||||||||||||||
| let profileExisted: boolean | undefined | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| profileExisted = await this.providerSettingsManager.hasConfig(name) | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| // Existence is unknown; rollback will restore/no-op rather than delete. | ||||||||||||||||||||||||||||||
| profileExisted = undefined | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+1923
to
+1929
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Existence-probe failure leaves a delete-unsafe state, but the rollback handles it correctly. If Abort the write when existence cannot be determined. This matches the confirmed-existing case. Proposed fix let profileExisted: boolean | undefined
try {
profileExisted = await this.providerSettingsManager.hasConfig(name)
- } catch {
- // Existence is unknown; rollback will restore/no-op rather than delete.
- profileExisted = undefined
+ } catch (error) {
+ // Existence is unknown; abort before any write so rollback state is always known.
+ throw error
}Based on learnings: "avoid silently swallowing errors ... Prefer throwing an explicit error to fail fast and preserve safety." 📝 Committable suggestion
Suggested change
🤖 Prompt for AI AgentsSource: Learnings |
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| let priorProfile: Awaited<ReturnType<ProviderSettingsManager["getProfile"]>> | undefined | ||||||||||||||||||||||||||||||
| if (profileExisted !== false) { | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| priorProfile = await this.providerSettingsManager.getProfile({ name }) | ||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||
| // The profile exists (or existence is unknown) but could not be read. | ||||||||||||||||||||||||||||||
| // When existence was confirmed, propagate so the write aborts *before* | ||||||||||||||||||||||||||||||
| // `saveConfig` rather than proceeding with an unknown prior state. | ||||||||||||||||||||||||||||||
| if (profileExisted === true) { | ||||||||||||||||||||||||||||||
| throw error | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const id = await this.providerSettingsManager.saveConfig(name, providerSettings) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if (signal.aborted) return id | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if (activate) { | ||||||||||||||||||||||||||||||
| const { mode } = await this.getState() | ||||||||||||||||||||||||||||||
| let priorModeConfigId: string | undefined | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| priorModeConfigId = await this.providerSettingsManager.getModeConfigId(mode) | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| // No prior mapping (or unavailable); nothing to restore for the mode. | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // These promises do the following: | ||||||||||||||||||||||||||||||
| // 1. Adds or updates the list of provider profiles. | ||||||||||||||||||||||||||||||
|
|
@@ -1860,19 +1965,55 @@ export class ClineProvider | |||||||||||||||||||||||||||||
| // this.contextProxy.setValues({ ...providerSettings, listApiConfigMeta: ..., currentApiConfigName: ... }) | ||||||||||||||||||||||||||||||
| // We should probably switch to that and verify that it works. | ||||||||||||||||||||||||||||||
| // I left the original implementation in just to be safe. | ||||||||||||||||||||||||||||||
| await Promise.all([ | ||||||||||||||||||||||||||||||
| this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()), | ||||||||||||||||||||||||||||||
| this.updateGlobalState("currentApiConfigName", name), | ||||||||||||||||||||||||||||||
| this.providerSettingsManager.setModeConfig(mode, id), | ||||||||||||||||||||||||||||||
| this.contextProxy.setProviderSettings(providerSettings), | ||||||||||||||||||||||||||||||
| ]) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Change the provider for the current task. | ||||||||||||||||||||||||||||||
| // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). | ||||||||||||||||||||||||||||||
| this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Keep the current task's sticky provider profile in sync with the newly-activated profile. | ||||||||||||||||||||||||||||||
| await this.persistStickyProviderProfileToCurrentTask(name) | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| await Promise.all([ | ||||||||||||||||||||||||||||||
| this.updateGlobalState( | ||||||||||||||||||||||||||||||
| "listApiConfigMeta", | ||||||||||||||||||||||||||||||
| await this.providerSettingsManager.listConfig(), | ||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||
| this.updateGlobalState("currentApiConfigName", name), | ||||||||||||||||||||||||||||||
| this.providerSettingsManager.setModeConfig(mode, id), | ||||||||||||||||||||||||||||||
| this.contextProxy.setProviderSettings(providerSettings), | ||||||||||||||||||||||||||||||
| ]) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Change the provider for the current task. | ||||||||||||||||||||||||||||||
| // TODO: We should rename `buildApiHandler` for clarity (e.g. `getProviderClient`). | ||||||||||||||||||||||||||||||
| this.updateTaskApiHandlerIfNeeded(providerSettings, { forceRebuild: true }) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| // Keep the current task's sticky provider profile in sync with the newly-activated profile. | ||||||||||||||||||||||||||||||
| await this.persistStickyProviderProfileToCurrentTask(name) | ||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||
| // Compensating rollback: restore the profile secret, the active | ||||||||||||||||||||||||||||||
| // profile name, the mode mapping and the in-memory provider | ||||||||||||||||||||||||||||||
| // settings to their pre-write values so a partial activation | ||||||||||||||||||||||||||||||
| // cannot report success while leaving inconsistent state. | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| if (priorProfile) { | ||||||||||||||||||||||||||||||
| await this.providerSettingsManager.saveConfig(name, priorProfile) | ||||||||||||||||||||||||||||||
| } else if (profileExisted === false) { | ||||||||||||||||||||||||||||||
| // Absence was confirmed before the write; remove the new profile. | ||||||||||||||||||||||||||||||
| // A swallowed read error (unknown existence) must never reach here. | ||||||||||||||||||||||||||||||
| await this.providerSettingsManager.deleteConfig(name) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| // A pre-existing mode mapping is restored; a newly created one | ||||||||||||||||||||||||||||||
| // has no delete API, so it is left as a best-effort remainder. | ||||||||||||||||||||||||||||||
| if (priorModeConfigId) { | ||||||||||||||||||||||||||||||
| await this.providerSettingsManager.setModeConfig(mode, priorModeConfigId) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| await this.contextProxy.setValue("currentApiConfigName", priorCurrentApiConfigName) | ||||||||||||||||||||||||||||||
| await this.contextProxy.setValues({ | ||||||||||||||||||||||||||||||
| listApiConfigMeta: await this.providerSettingsManager.listConfig(), | ||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||
| await this.contextProxy.setProviderSettings(priorProviderSettings) | ||||||||||||||||||||||||||||||
| } catch (rollbackError) { | ||||||||||||||||||||||||||||||
| this.log( | ||||||||||||||||||||||||||||||
| `[upsertProviderProfile] rollback failed for "${name}": ${ | ||||||||||||||||||||||||||||||
| rollbackError instanceof Error ? rollbackError.message : String(rollbackError) | ||||||||||||||||||||||||||||||
| }`, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| throw error | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||
| await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
@@ -2124,7 +2265,13 @@ export class ClineProvider | |||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| // Activate only if zoo-gateway was the active provider (shouldn't happen if | ||||||||||||||||||||||||||||||
| // no profiles exist, but defensive). | ||||||||||||||||||||||||||||||
| await this.upsertProviderProfile("Zoo Gateway", newConfiguration, isZooGatewayActive) | ||||||||||||||||||||||||||||||
| // | ||||||||||||||||||||||||||||||
| // `bypassAllowList`: internal auth credential write. `ProfileValidator` | ||||||||||||||||||||||||||||||
| // cannot map zoo-gateway to a model id, so a restrictive organization | ||||||||||||||||||||||||||||||
| // allow-list would reject this write and leave no credentials persisted. | ||||||||||||||||||||||||||||||
| await this.upsertProviderProfile("Zoo Gateway", newConfiguration, isZooGatewayActive, { | ||||||||||||||||||||||||||||||
| bypassAllowList: true, | ||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||
| // Update every existing zoo-gateway profile with the new token and the | ||||||||||||||||||||||||||||||
| // derived base URL so that environment-specific routing stays consistent. | ||||||||||||||||||||||||||||||
|
|
@@ -2139,7 +2286,8 @@ export class ClineProvider | |||||||||||||||||||||||||||||
| if (isActiveProfile) { | ||||||||||||||||||||||||||||||
| // Use upsertProviderProfile with activate: true so the in-memory handler | ||||||||||||||||||||||||||||||
| // picks up the new token immediately for the current task. | ||||||||||||||||||||||||||||||
| await this.upsertProviderProfile(entry.name, updated, true) | ||||||||||||||||||||||||||||||
| // `bypassAllowList`: internal auth credential write (see above). | ||||||||||||||||||||||||||||||
| await this.upsertProviderProfile(entry.name, updated, true, { bypassAllowList: true }) | ||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||
| // Non-active profiles just need the token saved to disk. | ||||||||||||||||||||||||||||||
| await this.providerSettingsManager.saveConfig(entry.name, updated) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.