Add deferred first-send model gate

Move missing-model setup out of the welcome screen and into an in-thread gate that preserves the pending prompt, survives refresh, and auto-dispatches after a usable model is configured.

Reuse existing onboarding, OAuth account settings, and advanced model configuration surfaces instead of duplicating provider forms. Connected OAuth accounts can now populate Main and Utility defaults when no chat model is configured.

Update model readiness checks, focused static regressions, and DOX contracts for the new deferred setup flow.
This commit is contained in:
Alessandro 2026-07-02 11:46:01 +02:00
parent 038f88848d
commit 04ecaac0cd
23 changed files with 695 additions and 285 deletions

View file

@ -16,6 +16,7 @@
- CTA actions must match supported welcome-screen action contracts.
- Keep card IDs unique and plugin-prefixed.
- The Welcome screen `welcome-actions-end` surface renders feature channel cards plus the compact OAuth account-provider card; other hero discovery cards stay out of the lower welcome grid.
- Do not hide discovery cards solely because model setup is incomplete; model setup gating belongs to the chat thread.
## Work Guidance

View file

@ -7,7 +7,7 @@
<div class="discovery-slot"
@modal-closed.window="$store.discoveryStore.refreshCards()"
x-create="$store.discoveryStore.refreshCards()"
x-show="!($store.welcomeStore?.banners || []).some(b => b.id === 'missing-api-key') && ($store.discoveryStore.featureCards.length > 0 || $store.discoveryStore.oauthAccountCards.length > 0 || $store.discoveryStore.hasDismissedCards)">
x-show="$store.discoveryStore.featureCards.length > 0 || $store.discoveryStore.oauthAccountCards.length > 0 || $store.discoveryStore.hasDismissedCards">
<section class="discovery-features-panel" x-show="$store.discoveryStore.featureCards.length > 0">
<header class="discovery-panel-header">

View file

@ -17,6 +17,7 @@
- Project Settings `llm` payloads are owned here through the generic `helpers.projects` project extension-data hooks; keep project helper code agnostic to `_model_config` paths, presets, and inheritance rules.
- Keep provider metadata and API-key checks safe around secrets.
- Coordinate OAuth-backed providers with `_oauth` instead of hardcoding provider-specific auth here.
- `model_config_get` exposes `model_configured` as a derived chat-model readiness flag from provider, model name, and API-key availability.
- Applying a model preset may inherit durable tuning such as context windows and rate limits, but must replace or clear per-slot `kwargs` so provider-specific extra params never leak across model providers.
- Repair provider-specific model-config aliases at the model-config read/build boundary; keep provider-specific repairs out of provider-agnostic core wrappers such as `models.py`.

View file

@ -36,6 +36,10 @@ class ModelConfigGet(ApiHandler):
key = models.get_api_key(pid)
api_key_status[pid] = bool(key and key.strip() and key != "None")
chat_model = config.get("chat_model", {}) if isinstance(config, dict) else {}
chat_provider = str(chat_model.get("provider") or "").strip()
chat_name = str(chat_model.get("name") or "").strip()
return {
"config": config,
"chat_providers": chat_providers,
@ -43,4 +47,8 @@ class ModelConfigGet(ApiHandler):
"chat_provider_details": chat_provider_details,
"embedding_provider_details": embedding_provider_details,
"api_key_status": api_key_status,
"model_configured": model_config.is_chat_model_configured(config),
"model_configured_label": " / ".join(
part for part in (chat_provider, chat_name) if part
),
}

View file

@ -26,10 +26,6 @@ class MissingApiKeyCheck(Extension):
"cta_action": f"open-modal:{self.ONBOARDING_MODAL_PATH}",
"dismissible": False,
"source": "backend",
"auto_modal_path": self.ONBOARDING_MODAL_PATH,
"auto_modal_reason": "missing-api-key",
"auto_modal_priority": 100,
"auto_modal_surfaces": ["welcome", "chat-created"],
# For programmatic clients (e.g. chat composer) reusing this banner pipeline
"missing_providers": missing_providers,
})

View file

@ -671,3 +671,13 @@ def get_missing_api_key_providers(agent=None) -> list[dict]:
missing.append({"model_type": label, "provider": provider})
return missing
def is_chat_model_configured(config: dict | None = None) -> bool:
cfg = config if isinstance(config, dict) else get_config()
chat_cfg = cfg.get("chat_model", {}) if isinstance(cfg, dict) else {}
provider = str(chat_cfg.get("provider") or "").strip()
name = str(chat_cfg.get("name") or "").strip()
if not provider or not name:
return False
return has_provider_api_key(provider.lower(), chat_cfg.get("api_key", ""), "chat")

View file

@ -158,6 +158,8 @@ export const store = createStore("modelConfig", {
embeddingProviders: [],
chatProviderDetails: [],
embeddingProviderDetails: [],
modelConfigured: false,
modelConfiguredLabel: "",
_loaded: false,
// API Keys state (from mixin)
@ -203,6 +205,8 @@ export const store = createStore("modelConfig", {
this.chatProviderDetails = data.chat_provider_details || [];
this.embeddingProviderDetails = data.embedding_provider_details || [];
this.apiKeyStatus = data.api_key_status || {};
this.modelConfigured = !!data.model_configured;
this.modelConfiguredLabel = data.model_configured_label || "";
const keys = {};
const dirty = {};
const seen = new Set();

View file

@ -28,6 +28,8 @@
- 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.
- `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,6 +287,15 @@ 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}`;
@ -670,6 +679,61 @@ 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) {
if (typeof document === "undefined") return;
document.dispatchEvent(new CustomEvent("model-configured", {
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);
},
async loadStatus() {
if (this.loadingStatus) return;
this.loadingStatus = true;
@ -788,7 +852,7 @@ export const store = createStore("oauthConfig", {
throw new Error(response?.error || `Could not finish ${this.providerLabel(providerId)} sign-in.`);
}
if (response.completed) {
await this.loadStatus();
await this.handleProviderConnected(providerId);
this.clearProviderDevice(providerId);
this.stopPolling(providerId);
if (this.connectingProvider === providerId) this.connectingProvider = "";
@ -840,6 +904,7 @@ export const store = createStore("oauthConfig", {
const tick = async () => {
await this.loadStatus();
if (this.providerConnected(providerId)) {
await this.handleProviderConnected(providerId, { statusLoaded: true });
this.clearProviderDevice(providerId);
this.stopCallbackPolling(providerId);
if (this.connectingProvider === providerId) this.connectingProvider = "";
@ -932,7 +997,7 @@ export const store = createStore("oauthConfig", {
this.providerUiFor(providerId).manualCallback = "";
this.clearProviderDevice(providerId);
this.stopCallbackPolling(providerId);
await this.loadStatus();
await this.handleProviderConnected(providerId);
void toastFrontendSuccess(`${this.providerLabel(providerId)} connected.`, "OAuth Connections");
} catch (error) {
void toastFrontendError(messageOf(error), "OAuth Connections");