Require explicit model choice after OAuth connect

Stop auto-populating Main and Utility models when an OAuth account is already connected or newly connected.

Add a connected-account gate state that routes users to model configuration instead of choosing provider defaults silently.

Update OAuth notifications, chat gate copy, DOX contracts, and focused static regressions for the explicit model-selection flow.
This commit is contained in:
Alessandro 2026-07-02 12:04:12 +02:00
parent 04ecaac0cd
commit b75b0f2c5c
7 changed files with 54 additions and 80 deletions

View file

@ -28,8 +28,7 @@
- OAuth settings pending-auth controls such as device codes, manual callback input, and provider setup fields must render inline under the relevant provider row, not as a detached section below all providers.
- OAuth device-code polling must honor provider `interval`, `expires_at`, and `slow_down` updates; do not poll immediately or keep a stale fixed interval after a provider asks the client to slow down.
- OAuth settings model slots must keep provider choice editable per slot, list only connected OAuth account providers, and persist the selected provider IDs into `chat_model.provider` and `utility_model.provider`.
- The OAuth WebUI store owns connected-provider default selection helpers reused by settings and deferred chat setup gates.
- OAuth settings must dispatch `model-configured` when a provider connection completes. If no chat model is configured, the settings flow may apply the connected provider defaults to Main and Utility before notifying deferred send gates.
- OAuth settings must dispatch `model-setup-changed` when a provider connection completes, but model selection remains explicit and must not be filled automatically.
- `helpers/providers/registry.py` is the source of truth for connectable OAuth providers.
- OAuth provider config must not expose the dummy `oauth` API key in `conf/model_providers.yaml`; the dummy key is a runtime-only shim supplied by the `get_api_key` extension after the account provider reports connected.
- Usage-plan metadata belongs only to connectable providers. Do not add metadata-only subscription families for providers this plugin cannot connect.

View file

@ -287,15 +287,6 @@ export const store = createStore("oauthConfig", {
return status.display_name || providerId;
},
providerDefaultModel(providerId, slotKey = "chat_model") {
const status = this.providerStatus(providerId);
const defaults = Array.isArray(status.default_models)
? status.default_models.map((model) => String(model || "").trim()).filter(Boolean)
: [];
if (slotKey === "utility_model" && defaults[1]) return defaults[1];
return String(status.default_model || defaults[0] || "").trim();
},
providerUseLabel(providerId) {
const status = this.providerStatus(providerId);
return status.use_label || `Use ${status.short_name || status.display_name || providerId}`;
@ -679,59 +670,16 @@ export const store = createStore("oauthConfig", {
}
},
async currentChatModelConfigured() {
try {
const response = await callJsonApi(`${MODEL_CONFIG_API}/model_config_get`, {});
return Boolean(response?.model_configured);
} catch (error) {
console.error("Could not check model configuration:", error);
return true;
}
},
async autoApplyConnectedProviderIfNeeded(providerId) {
if (!this.providerConnected(providerId)) return false;
if (await this.currentChatModelConfigured()) return false;
await this.loadModelConfig();
if (!this.modelConfig) return false;
const chatModel = this.providerDefaultModel(providerId, "chat_model");
if (!chatModel) return false;
const utilityModel = this.providerDefaultModel(providerId, "utility_model") || chatModel;
const chat = this.modelSlot("chat_model");
const utility = this.modelSlot("utility_model");
chat.provider = providerId;
chat.name = chatModel;
chat.api_base = "";
if (!chat.kwargs || typeof chat.kwargs !== "object") chat.kwargs = {};
utility.provider = providerId;
utility.name = utilityModel;
utility.api_base = "";
if (!utility.kwargs || typeof utility.kwargs !== "object") utility.kwargs = {};
this.activeModelProvider = providerId;
this.models = this.activeProviderModels();
this.modelConfigDirty = true;
this.modelSlotDirty = { chat_model: true, utility_model: true };
await this.saveModelConfigIfDirty();
return true;
},
notifyModelConfigured(providerId) {
notifyModelSetupChanged(providerId) {
if (typeof document === "undefined") return;
document.dispatchEvent(new CustomEvent("model-configured", {
document.dispatchEvent(new CustomEvent("model-setup-changed", {
detail: { source: "_oauth", providerId },
}));
},
async handleProviderConnected(providerId, { statusLoaded = false } = {}) {
if (!statusLoaded) await this.loadStatus();
try {
await this.autoApplyConnectedProviderIfNeeded(providerId);
} catch (error) {
void toastFrontendError(messageOf(error), "OAuth Connections");
}
this.notifyModelConfigured(providerId);
this.notifyModelSetupChanged(providerId);
},
async loadStatus() {

View file

@ -116,12 +116,10 @@ def test_oauth_model_slots_reuse_model_config_api():
assert "if (!this.providerConnected(providerId)) return;" in store_js
assert "const providerId = slot.provider;" in store_js
assert "const providerId = this.isOauthProvider(this.activeModelProvider)" not in store_js
assert "autoApplyConnectedProviderIfNeeded(providerId)" in store_js
assert "currentChatModelConfigured()" in store_js
assert 'providerDefaultModel(providerId, "chat_model")' in store_js
assert 'providerDefaultModel(providerId, "utility_model")' in store_js
assert "this.modelSlotDirty = { chat_model: true, utility_model: true };" in store_js
assert 'new CustomEvent("model-configured"' in store_js
assert "autoApplyConnectedProviderIfNeeded" not in store_js
assert "currentChatModelConfigured" not in store_js
assert "providerDefaultModel" not in store_js
assert 'new CustomEvent("model-setup-changed"' in store_js
assert 'detail: { source: "_oauth", providerId }' in store_js
assert "await this.handleProviderConnected(providerId)" in store_js

View file

@ -111,13 +111,15 @@ def test_welcome_composer_can_create_a_chat_before_sending() -> None:
assert "sessionStorage.getItem(STORAGE_KEY)" in gate_store
assert "sessionStorage.removeItem(STORAGE_KEY)" in gate_store
assert 'import("/plugins/_oauth/webui/oauth-config-store.js")' in gate_store
assert "tryConnectedAccountDefaults()" in gate_store
assert "oauthConfigStore.connectedProviderCards()[0]?.provider_id" in gate_store
assert "oauthConfigStore.autoApplyConnectedProviderIfNeeded(providerId)" in gate_store
assert "const refreshed = await callJsonApi(\"/plugins/_model_config/model_config_get\", {});" in gate_store
assert "refreshConnectedAccountState()" in gate_store
assert "accountConnected" in gate_store
assert "accountLabel" in gate_store
assert "oauthConfigStore.connectedProviderCards()[0] || null" in gate_store
assert "autoApplyConnectedProviderIfNeeded" not in gate_store
assert "void this.dispatchPendingIfConfigured();" in gate_store
assert "this.choice = \"\";" in gate_store
assert 'document.addEventListener("model-configured"' in gate_store
assert 'document.addEventListener("model-setup-changed"' in gate_store
assert "const currentContext = globalThis.getContext?.();" in gate_store
assert "bypassModelGate: true" in gate_store
assert 'openPluginConfig("_model_config", "Advanced model configuration")' in gate_store
@ -126,6 +128,8 @@ def test_welcome_composer_can_create_a_chat_before_sending() -> None:
assert "openOnboarding('cloud')" in gate_component
assert "openOnboarding('local')" in gate_component
assert "openOauthConfiguration()" in gate_component
assert "Choose models" in gate_component
assert "Connected account:" in gate_component
assert "Advanced model configuration" in gate_component
assert "Advanced settings" not in gate_component
assert "model-gate-fields" not in gate_component

View file

@ -23,8 +23,8 @@
- Missing model setup is gated at send intent: the first unconfigured send renders an in-thread setup card, keeps the pending prompt in browser session storage for refresh recovery, and must not call `/message_async` until a chat model is configured.
- While the setup gate is open, the composer remains typeable but send is blocked until setup succeeds.
- The setup gate must delegate Cloud/Local setup, account connections, and advanced model configuration to the existing onboarding and plugin settings modals; do not duplicate provider/model/key forms inline.
- Before blocking, the setup gate should reuse existing connected OAuth account defaults when they can make the chat model usable.
- Model setup surfaces that make the chat model usable must notify the gate with `model-configured` or an existing modal/onboarding completion signal so the pending prompt can retry automatically.
- A connected OAuth account without Main/Utility model selection is its own gate state; route to model configuration and do not select models automatically.
- Model setup surfaces that change readiness must notify the gate with `model-setup-changed`, `model-configured`, or an existing modal/onboarding completion signal so the pending prompt can retry automatically.
## Work Guidance

View file

@ -13,6 +13,8 @@ export const store = createStore("modelGate", {
active: false,
connected: false,
connectedLabel: "",
accountConnected: false,
accountLabel: "",
pending: null,
gateMessageId: "",
choice: "",
@ -30,6 +32,9 @@ export const store = createStore("modelGate", {
if (this.connected) {
return `Model connected: ${this.connectedLabel || "ready"}`;
}
if (this.accountConnected) {
return `${this.accountLabel || "Your AI account"} is connected. Choose Main and Utility models and I'll answer right away.`;
}
return "I'm ready to work on this — I just need a model to think with. Pick one and I'll answer right away.";
},
@ -43,6 +48,9 @@ export const store = createStore("modelGate", {
document.addEventListener("model-configured", () => {
void this.dispatchPendingIfConfigured();
});
document.addEventListener("model-setup-changed", () => {
void this.dispatchPendingIfConfigured();
});
document.addEventListener("modal-closed", () => {
if (this.active && !this.connected) {
this.choice = "";
@ -56,25 +64,26 @@ export const store = createStore("modelGate", {
try {
const data = await callJsonApi("/plugins/_model_config/model_config_get", {});
this.applyModelStatus(data);
if (data?.model_configured) return true;
if (!(await this.tryConnectedAccountDefaults())) return false;
const refreshed = await callJsonApi("/plugins/_model_config/model_config_get", {});
this.applyModelStatus(refreshed);
return !!refreshed?.model_configured;
if (!data?.model_configured) await this.refreshConnectedAccountState();
return !!data?.model_configured;
} catch (error) {
console.error("Could not check model configuration:", error);
return true;
}
},
async tryConnectedAccountDefaults() {
async refreshConnectedAccountState() {
try {
const { store: oauthConfigStore } = await import("/plugins/_oauth/webui/oauth-config-store.js");
await oauthConfigStore.loadStatus();
const providerId = oauthConfigStore.connectedProviderCards()[0]?.provider_id || "";
return providerId ? oauthConfigStore.autoApplyConnectedProviderIfNeeded(providerId) : false;
const account = oauthConfigStore.connectedProviderCards()[0] || null;
this.accountConnected = Boolean(account);
this.accountLabel = account?.short_name || account?.display_name || account?.provider_id || "";
return this.accountConnected;
} catch (error) {
console.error("Could not apply connected account defaults:", error);
console.error("Could not check connected accounts:", error);
this.accountConnected = false;
this.accountLabel = "";
return false;
}
},

View file

@ -20,7 +20,19 @@
<p class="model-gate-intro" x-text="$store.modelGate.introText"></p>
<div class="model-gate-card">
<div class="model-gate-fork">
<div class="model-gate-fork is-single" x-show="$store.modelGate.accountConnected">
<button type="button"
class="model-gate-option"
@click="$store.modelGate.openAdvancedModelConfiguration()">
<span class="model-gate-option-title">
<span class="material-symbols-outlined" aria-hidden="true">tune</span>
<span>Choose models</span>
</span>
<span class="model-gate-option-sub">Select Main and Utility models</span>
</button>
</div>
<div class="model-gate-fork" x-show="!$store.modelGate.accountConnected">
<button type="button"
class="model-gate-option"
:class="{ selected: $store.modelGate.choice === 'cloud' }"
@ -44,7 +56,7 @@
</div>
<div class="model-gate-accounts">
<span>Or connect an account: Codex, Copilot, Gemini, Grok</span>
<span x-text="$store.modelGate.accountConnected ? `Connected account: ${$store.modelGate.accountLabel || 'ready'}` : 'Or connect an account: Codex, Copilot, Gemini, Grok'"></span>
<button type="button" @click="$store.modelGate.openOauthConfiguration()">
<span>Show accounts</span>
</button>
@ -90,6 +102,10 @@
gap: 0.6rem;
}
.model-gate-fork.is-single {
grid-template-columns: 1fr;
}
.model-gate-option {
border: 1px solid color-mix(in srgb, var(--color-border) 76%, transparent);
border-radius: 8px;