diff --git a/apps/web/src/components/upstream-edit/CustomConfigPanel.vue b/apps/web/src/components/upstream-edit/CustomConfigPanel.vue index 31de30d6e..3518822b5 100644 --- a/apps/web/src/components/upstream-edit/CustomConfigPanel.vue +++ b/apps/web/src/components/upstream-edit/CustomConfigPanel.vue @@ -115,7 +115,7 @@ const setAuthStyle = (style: CustomAuthStyle) => {

Fetch /models

-

{{ fetchStatus }}

+

{{ fetchStatus }}

{ @update:model-value="v => draft = { ...draft, modelsFetch: { ...draft.modelsFetch, endpoint: v } }" />
-
+

Fetch models

diff --git a/apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue b/apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue index 742f4a578..6d98a5e9f 100644 --- a/apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue +++ b/apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue @@ -41,9 +41,9 @@ const props = defineProps<{ availableModelItems: { value: string; label: string }[]; // Live cache snapshot for the saved upstream. Null in create mode and for // Azure (which has no fetch step) — `ModelsCacheStatus` is rendered only - // when this is provided. + // when this is provided. The panel is informational only; the "Fetch" + // button in the per-provider config panel is the sole re-fetch entry. modelsCache: UpstreamRecord['modelsCache'] | null; - refreshing: boolean; saving: boolean; coloAware: boolean; currentColo: string | null; @@ -51,7 +51,6 @@ const props = defineProps<{ defineEmits<{ 'fetch-models': []; - 'refresh-cache': []; patched: [patch: { config?: unknown; state?: unknown }]; 'save-and-open-edit': []; error: [message: string]; @@ -208,11 +207,7 @@ onBeforeUnmount(() => floorObserver?.disconnect());

Models Cache

- +
diff --git a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue index 4349ed248..bf6df7985 100644 --- a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue +++ b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue @@ -13,7 +13,10 @@ import { blankAzureDraft, blankCustomDraft, blankOllamaDraft, + buildAzureConfig, buildCustomConfigCore, + buildListModelsPreviewConfig, + buildOllamaConfig, type CustomDraft, type OllamaDraft, seedPathOverrides, @@ -129,15 +132,10 @@ const modelPrefixInvalid = ref(false); const upstreamModels = ref([]); const upstreamModelsError = ref(null); -// Create-mode draft preview state for the inline "Fetch" button on the -// Custom and Ollama panels: `POST /api/upstreams/list-models` returns the -// unsaved config's catalog so rows can be picked before saving. -// `fetchedRaw` carries the Custom raw rows (translated through the draft's -// endpoints by `customAutoModelsFromDraft`); `fetchedOllamaModels` carries -// the Ollama rows the backend already projected — no further translation -// needed since the per-model endpoints fall out of upstream capabilities. +// `fetchedRaw` is the Custom-only raw slot — rows get translated through the +// draft's endpoints via `customAutoModelsFromDraft`; Ollama's Fetch result +// lands in `upstreamModels` alongside the mount-time prime. const fetchedRaw = ref([]); -const fetchedOllamaModels = ref([]); const fetchLoading = ref(false); const fetchError = ref(null); const fetchedAtMs = ref(null); @@ -173,17 +171,17 @@ const customAutoModelsFromDraft = computed(() => fetchedR // already-projected `UpstreamModelConfig`. type ListModelsResult = { data: UpstreamModelConfig[] } | { data: CustomRawModel[] }; +const applyListModelsResult = (data: ListModelsResult['data']): void => { + if (draft.value.kind === 'custom') fetchedRaw.value = data as CustomRawModel[]; + else upstreamModels.value = data as UpstreamModelConfig[]; +}; + const listDraftModels = async () => { if (draft.value.kind !== 'custom' && draft.value.kind !== 'ollama') return; fetchLoading.value = true; fetchError.value = null; try { - // Merge the current form drafts into the payload so the preview - // reflects the in-flight edits (baseUrl, apiKey, models) - // rather than the record's persisted config. - const config = draft.value.kind === 'custom' - ? { ...buildCustomConfigCore(customDraft.value), models: customDraft.value.models } - : buildOllamaConfig(); + const config = buildListModelsPreviewConfig(draft.value, customDraft.value, ollamaDraft.value, isCreate.value); const previewRecord = { ...toRecordEnvelope(draft.value), config }; const { data, error } = await callApi( () => api.api.upstreams['list-models'].$post({ json: { record: previewRecord } }), @@ -193,13 +191,18 @@ const listDraftModels = async () => { // discard the late result rather than repopulating stale auto rows. if (draft.value.kind === 'custom' && !customDraft.value.modelsFetch.enabled) return; if (error) { fetchError.value = error.message; return; } - if (draft.value.kind === 'custom') { - fetchedRaw.value = data.data as CustomRawModel[]; - } else { - fetchedOllamaModels.value = data.data as UpstreamModelConfig[]; - } + applyListModelsResult(data.data); fetchedCount.value = data.data.length; fetchedAtMs.value = Date.now(); + // Edit mode: the server-side list-models refreshed the SWR cache too + // (record.id !== '' branch in the handler), so reload the store and + // fold the freshest `modelsCache.fetchedAt / lastError` back into draft + // — the Models Cache info panel is a passive reflection of this state. + if (!isCreate.value) { + await upstreamsStore.load(); + const refreshed = upstreamsStore.upstreams.value?.find(u => u.id === draft.value.id); + if (refreshed) draft.value = { ...draft.value, modelsCache: refreshed.modelsCache }; + } } finally { fetchLoading.value = false; } @@ -240,14 +243,9 @@ const hasCredentialForFetch = computed(() => { // Fetch the live model catalog for the current draft. Skipped for Azure // (operator-edited catalog, no upstream `/models` endpoint) and when the -// draft has no credential yet (blueprint state). For custom the server -// returns raw rows the dashboard translates through the draft's endpoints, -// so route them into `fetchedRaw` — the same slot the unsaved draft -// preview uses; every other kind receives already-projected -// UpstreamModelConfig rows and lands in `upstreamModels`. Surfaces the -// error on `upstreamModelsError` otherwise. Returns nothing — callers -// wrap this with their own bookkeeping (mount-time prime, operator-driven -// refresh). +// draft has no credential yet (blueprint state). Called once on mount to +// prime ModelsPanel; the operator-driven "Fetch" button goes through +// listDraftModels instead so it can post the in-flight form config. const fetchUpstreamModels = async () => { if (draft.value.kind === 'azure') return; if (!hasCredentialForFetch.value) return; @@ -256,32 +254,12 @@ const fetchUpstreamModels = async () => { () => api.api.upstreams['list-models'].$post({ json: { record: toRecordEnvelope(draft.value) } }), ); if (error) { upstreamModelsError.value = error.message; return; } - if (draft.value.kind === 'custom') { - fetchedRaw.value = data.data as CustomRawModel[]; - } else { - upstreamModels.value = data.data as UpstreamModelConfig[]; - } -}; - -const refreshing = ref(false); -const refreshCachedModels = async () => { - refreshing.value = true; - try { - await fetchUpstreamModels(); - if (upstreamModelsError.value) return; - // The server-side list-models refreshed the SWR cache too; reload the - // store so the header's `modelsCache` summary reflects the freshest - // fetchedAt / lastError the gateway just wrote. - await upstreamsStore.load(); - const refreshed = upstreamsStore.upstreams.value?.find(u => u.id === draft.value.id); - if (refreshed) draft.value = { ...draft.value, modelsCache: refreshed.modelsCache }; - } finally { - refreshing.value = false; - } + applyListModelsResult(data.data); }; -// Prime on mount so ModelsPanel renders populated; refresh button reruns -// the same call plus the store reload above. +// Prime on mount so ModelsPanel renders populated; the operator-driven +// "Fetch" button reruns list-models with the in-flight form config (see +// listDraftModels). void fetchUpstreamModels(); const saving = ref(false); @@ -303,24 +281,6 @@ const buildCustomConfig = () => { return config; }; -const buildAzureConfig = () => { - const config: Record = { - endpoint: azureDraft.value.endpoint.trim(), - models: azureDraft.value.models, - }; - if (azureDraft.value.apiKey.trim()) config.apiKey = azureDraft.value.apiKey.trim(); - return config; -}; - -const buildOllamaConfig = () => { - const config: Record = { - baseUrl: ollamaDraft.value.baseUrl.trim(), - models: ollamaDraft.value.models, - }; - if (ollamaDraft.value.apiKey.trim()) config.apiKey = ollamaDraft.value.apiKey.trim(); - return config; -}; - // Editable providers (custom/azure/ollama) rebuild the config from the // per-provider form draft; OAuth providers hand back the credential slice // their wizards populated in draft.config / draft.state. In edit state the @@ -328,8 +288,8 @@ const buildOllamaConfig = () => { // pass here is ignored server-side — it's still safe to include. const buildConfigForSave = (): unknown => { if (draft.value.kind === 'custom') return buildCustomConfig(); - if (draft.value.kind === 'azure') return buildAzureConfig(); - if (draft.value.kind === 'ollama') return buildOllamaConfig(); + if (draft.value.kind === 'azure') return buildAzureConfig(azureDraft.value); + if (draft.value.kind === 'ollama') return buildOllamaConfig(ollamaDraft.value); return draft.value.config; }; @@ -459,21 +419,16 @@ const modelsManualForActive = computed({ }, }); -// Auto rows are the live catalog the upstream itself decides. Saved rows -// resolve through the SWR cache via `upstreamModels`; unsaved custom / -// ollama drafts fall back to the inline list-models preview. +// Auto rows are the live catalog the upstream itself decides. Ollama and the +// OAuth kinds land the projected UpstreamModelConfig rows in `upstreamModels` +// (populated by the mount-time prime and the inline "Fetch" preview alike); +// Custom keeps a separate raw slot so the dashboard can translate through +// the draft's endpoints. const autoForActive = computed(() => { if (draft.value.kind === 'custom') { if (!customDraft.value.modelsFetch.enabled) return []; - // Both saved and unsaved custom rows read the raw catalog from - // `fetchedRaw` and translate through the draft's endpoints — the - // server returns raw upstream rows uniformly for both call paths. return customAutoModelsFromDraft.value; } - if (draft.value.kind === 'ollama') { - if (!isCreate.value) return upstreamModels.value; - return fetchedOllamaModels.value; - } return upstreamModels.value; }); @@ -586,10 +541,8 @@ const workbenchStyle = computed(() => ({ '--right-pane-h': `${Math.ceil(rightCon :fetch-status="fetchStatus" :available-model-items="availableModelItems" :models-cache="showCacheStatus ? draft.modelsCache : null" - :refreshing="refreshing" :saving="saving" @fetch-models="listDraftModels" - @refresh-cache="refreshCachedModels" @patched="applyPatch" @save-and-open-edit="save({ openEdit: true })" @error="onError" diff --git a/apps/web/src/components/upstream-edit/customConfig.ts b/apps/web/src/components/upstream-edit/customConfig.ts index a339eb0fa..963799f86 100644 --- a/apps/web/src/components/upstream-edit/customConfig.ts +++ b/apps/web/src/components/upstream-edit/customConfig.ts @@ -1,4 +1,4 @@ -import type { ModelEndpoints, UpstreamModelConfig } from '../../api/types.ts'; +import type { ModelEndpoints, UpstreamModelConfig, UpstreamRecord } from '../../api/types.ts'; export const PATH_KEYS = [ '/completions', @@ -124,3 +124,50 @@ export const blankOllamaDraft = (): OllamaDraft => ({ apiKey: '', models: [], }); + +// Wire-shape projection of OllamaDraft. Shared by save() and the list-models +// preview so the two paths cannot drift. +export const buildOllamaConfig = (draft: OllamaDraft): Record => { + const config: Record = { + baseUrl: draft.baseUrl.trim(), + models: draft.models, + }; + if (draft.apiKey.trim()) config.apiKey = draft.apiKey.trim(); + return config; +}; + +// Wire-shape projection of AzureDraft. Azure has no list-models path (the +// catalog is operator-authored), so this only feeds save(). +export const buildAzureConfig = (draft: AzureDraft): Record => { + const config: Record = { + endpoint: draft.endpoint.trim(), + models: draft.models, + }; + if (draft.apiKey.trim()) config.apiKey = draft.apiKey.trim(); + return config; +}; + +// Wire-shape projection for POST /api/upstreams/list-models. Consolidates the +// per-kind builder dispatch and the edit-mode stored-secret fallback so the +// preview probes the same shape save() would write. Matches save()'s +// backend-patch-omit semantics — an untyped apiKey in edit mode falls back to +// the record's stored secret so the preview does not hit an unauthenticated +// upstream when the operator merely tweaks non-credential fields. +export const buildListModelsPreviewConfig = ( + record: UpstreamRecord, + customDraft: CustomDraft, + ollamaDraft: OllamaDraft, + isCreate: boolean, +): Record => { + const config: Record = record.kind === 'custom' + ? { ...buildCustomConfigCore(customDraft), models: customDraft.models } + : buildOllamaConfig(ollamaDraft); + if (!isCreate && config.apiKey === undefined) { + if (record.kind === 'custom' && record.config.authStyle !== 'none' && record.config.apiKey) { + config.apiKey = record.config.apiKey; + } else if (record.kind === 'ollama' && record.config.apiKey) { + config.apiKey = record.config.apiKey; + } + } + return config; +}; diff --git a/packages/gateway/src/control-plane/upstreams/routes.ts b/packages/gateway/src/control-plane/upstreams/routes.ts index a2549164c..efc048a5f 100644 --- a/packages/gateway/src/control-plane/upstreams/routes.ts +++ b/packages/gateway/src/control-plane/upstreams/routes.ts @@ -85,14 +85,19 @@ const codexQuotaForResponse = async (record: UpstreamRecord): Promise => { +// so it stays a pure persisted-record transform. The optional baseSerialize +// override lets callers swap in upstreamRecordToFullJson to round-trip +// unredacted secrets instead of the redacted default. +const serializeForResponse = async ( + record: UpstreamRecord, + baseSerialize: (r: UpstreamRecord) => SerializedUpstreamRecord = upstreamRecordToJson, +): Promise => { const [cacheRow, codexQuota] = await Promise.all([ getRepo().modelsCache.get(record.id), codexQuotaForResponse(record), ]); return { - ...upstreamRecordToJson(record), + ...baseSerialize(record), modelsCache: { fetchedAt: cacheRow?.fetchedAt ?? null, lastError: cacheRow?.lastError ?? null, @@ -205,7 +210,7 @@ const validateProxyFallbackList = async (entries: readonly ProxyFallbackEntry[]) export const listUpstreams = async (c: Context) => { const items = await getRepo().upstreams.list(); - return c.json(await Promise.all(items.map(serializeForResponse))); + return c.json(await Promise.all(items.map(record => serializeForResponse(record)))); }; // Picker dataset for the per-key upstream whitelist editor. Non-admin users @@ -247,17 +252,15 @@ export const getUpstreamBlueprint = (c: Context): Response => { // Single-record read for the edit page. Returns the FULL record — no // secret redaction — because every editor-scoped action posts the record // back to a helper endpoint that needs the same credentials the data plane -// uses (refresh tokens, api keys, etc.). Codex quota is a response-only -// projection, so it is attached here as well. +// uses (refresh tokens, api keys, etc.). Codex quota and modelsCache are +// response-only projections, so they are attached here alongside the +// unredacted config/state — the edit page relies on `modelsCache` to +// render the "last fetched / last error" panel on mount. export const getUpstream = async (c: AuthedContext<'/:id'>) => { const id = c.req.param('id'); const record = await getRepo().upstreams.getById(id); if (!record) return c.json({ error: 'upstream not found' }, 404); - const response: UpstreamResponse = { - ...upstreamRecordToFullJson(record), - ...await codexQuotaForResponse(record), - }; - return c.json(response); + return c.json(await serializeForResponse(record, upstreamRecordToFullJson)); }; export const createUpstream = async (c: CtxWithJson) => {