From a68893de82c4581575fc7640b6e7d8b8c83d416b Mon Sep 17 00:00:00 2001 From: yyyr Date: Mon, 13 Jul 2026 05:29:42 +0800 Subject: [PATCH 1/8] feat(dashboard): unify upstream edit/create Fetch button UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix: getUpstream now returns modelsCache (was missing from single-record GET, only present in list/create/update — the Models Cache section in the edit page never appeared until a save had happened) - Fetch button in Custom/Ollama config panels is no longer create-only; edit mode gets the same in-flight preview UX as new upstream - listDraftModels fills apiKey from stored secret when the operator didn't retype it (edit mode), matching save()'s "empty means keep" semantics so the preview doesn't hit an unauthenticated upstream - listDraftModels reloads the store + reflects fresh modelsCache back into draft on success, replacing the removed Refresh now button - ModelsCacheStatus is now a passive info panel (last fetched / last error) with no button — Fetch owns the re-fetch action - unify fetchedOllamaModels into upstreamModels so autoForActive doesn't branch on isCreate for ollama --- .../upstream-edit/CustomConfigPanel.vue | 3 +- .../upstream-edit/ModelsCacheStatus.vue | 31 +++---- .../upstream-edit/OllamaConfigPanel.vue | 9 +- .../upstream-edit/UpstreamConfigPanel.vue | 11 +-- .../upstream-edit/UpstreamEditPage.vue | 82 +++++++++---------- .../src/control-plane/upstreams/routes.ts | 18 +++- 6 files changed, 76 insertions(+), 78 deletions(-) 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..3f056a424 100644 --- a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue +++ b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue @@ -129,15 +129,15 @@ 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. +// Draft preview state for the inline "Fetch" button on the Custom and Ollama +// panels: `POST /api/upstreams/list-models` returns the in-flight config's +// catalog so rows can be picked before saving (create) or before persisting +// edits (edit). `fetchedRaw` carries the Custom raw rows (translated through +// the draft's endpoints by `customAutoModelsFromDraft`); Ollama rows land in +// `upstreamModels` alongside the mount-time result — the two call sites +// produce the same UpstreamModelConfig shape, so a single slot keeps the +// display path uniform across create and edit. const fetchedRaw = ref([]); -const fetchedOllamaModels = ref([]); const fetchLoading = ref(false); const fetchError = ref(null); const fetchedAtMs = ref(null); @@ -181,9 +181,23 @@ const listDraftModels = async () => { // 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' + const config: Record = draft.value.kind === 'custom' ? { ...buildCustomConfigCore(customDraft.value), models: customDraft.value.models } : buildOllamaConfig(); + // Edit mode: an operator who did not retype the API key expects the + // preview to probe with the stored secret — matching how `save()` (via + // the backend's patch-omit semantics) treats an empty apiKey as "keep + // existing". `list-models` receives a full record envelope rather than a + // patch, so replicate the fallback client-side to avoid hitting an + // unauthenticated upstream. Applies only when the operator didn't type + // anything AND (for custom) the auth style still expects a key. + if (!isCreate.value && config.apiKey === undefined) { + if (draft.value.kind === 'custom' && draft.value.config.authStyle !== 'none' && draft.value.config.apiKey) { + config.apiKey = draft.value.config.apiKey; + } else if (draft.value.kind === 'ollama' && draft.value.config.apiKey) { + config.apiKey = draft.value.config.apiKey; + } + } const previewRecord = { ...toRecordEnvelope(draft.value), config }; const { data, error } = await callApi( () => api.api.upstreams['list-models'].$post({ json: { record: previewRecord } }), @@ -196,10 +210,19 @@ const listDraftModels = async () => { if (draft.value.kind === 'custom') { fetchedRaw.value = data.data as CustomRawModel[]; } else { - fetchedOllamaModels.value = data.data as UpstreamModelConfig[]; + upstreamModels.value = data.data as UpstreamModelConfig[]; } 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; } @@ -263,25 +286,9 @@ const fetchUpstreamModels = async () => { } }; -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; - } -}; - -// 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); @@ -459,21 +466,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 +588,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/packages/gateway/src/control-plane/upstreams/routes.ts b/packages/gateway/src/control-plane/upstreams/routes.ts index a2549164c..8be8caaae 100644 --- a/packages/gateway/src/control-plane/upstreams/routes.ts +++ b/packages/gateway/src/control-plane/upstreams/routes.ts @@ -247,15 +247,25 @@ 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 = { + const [cacheRow, codexQuota] = await Promise.all([ + getRepo().modelsCache.get(record.id), + codexQuotaForResponse(record), + ]); + const response: UpstreamWithCacheResponse = { ...upstreamRecordToFullJson(record), - ...await codexQuotaForResponse(record), + modelsCache: { + fetchedAt: cacheRow?.fetchedAt ?? null, + lastError: cacheRow?.lastError ?? null, + }, + ...codexQuota, }; return c.json(response); }; From f23e9a9c1ca5154f86fe7379baf379fb15fc86f3 Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 16 Jul 2026 13:43:43 +0800 Subject: [PATCH 2/8] refactor(gateway): parametrize serializeForResponse over the base serializer getUpstream inlined the same modelsCache + codexQuota projection serializeForResponse already owned, differing only in the redacted-vs- full serializer choice. Widen the helper to take the base serializer as an argument (defaulting to upstreamRecordToJson so list/create/update callers stay put) and let getUpstream pass upstreamRecordToFullJson. Response envelope is byte-identical. --- .../src/control-plane/upstreams/routes.ts | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/packages/gateway/src/control-plane/upstreams/routes.ts b/packages/gateway/src/control-plane/upstreams/routes.ts index 8be8caaae..6407b0af0 100644 --- a/packages/gateway/src/control-plane/upstreams/routes.ts +++ b/packages/gateway/src/control-plane/upstreams/routes.ts @@ -85,14 +85,20 @@ const codexQuotaForResponse = async (record: UpstreamRecord): Promise => { +// so it stays a pure persisted-record transform. Callers pick the base +// serializer: list/create/update stay on the redacted default; +// getUpstream passes upstreamRecordToFullJson so the edit page receives the +// unredacted secrets it needs to round-trip. +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 +211,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 @@ -255,19 +261,7 @@ 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 [cacheRow, codexQuota] = await Promise.all([ - getRepo().modelsCache.get(record.id), - codexQuotaForResponse(record), - ]); - const response: UpstreamWithCacheResponse = { - ...upstreamRecordToFullJson(record), - modelsCache: { - fetchedAt: cacheRow?.fetchedAt ?? null, - lastError: cacheRow?.lastError ?? null, - }, - ...codexQuota, - }; - return c.json(response); + return c.json(await serializeForResponse(record, upstreamRecordToFullJson)); }; export const createUpstream = async (c: CtxWithJson) => { From dc1d2b2c9c594d25f422c6d48ff6a815cc682aeb Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 16 Jul 2026 13:45:00 +0800 Subject: [PATCH 3/8] refactor(dashboard): move list-models preview payload shaping into customConfig listDraftModels open-coded the per-kind builder dispatch and the edit-mode stored-secret fallback, mixing form-draft-to-wire-shape policy into a Vue setup function whose primary job is UI orchestration. Move both into customConfig.ts alongside buildCustomConfigCore: the new buildListModelsPreviewConfig handles the dispatch and the fallback, and buildOllamaConfig moves next to it so save() and the preview share one wire-shape projection for ollama. --- .../upstream-edit/UpstreamEditPage.vue | 34 ++--------------- .../components/upstream-edit/customConfig.ts | 38 ++++++++++++++++++- 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue index 3f056a424..853701a1a 100644 --- a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue +++ b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue @@ -14,6 +14,8 @@ import { blankCustomDraft, blankOllamaDraft, buildCustomConfigCore, + buildListModelsPreviewConfig, + buildOllamaConfig, type CustomDraft, type OllamaDraft, seedPathOverrides, @@ -178,26 +180,7 @@ const listDraftModels = async () => { 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: Record = draft.value.kind === 'custom' - ? { ...buildCustomConfigCore(customDraft.value), models: customDraft.value.models } - : buildOllamaConfig(); - // Edit mode: an operator who did not retype the API key expects the - // preview to probe with the stored secret — matching how `save()` (via - // the backend's patch-omit semantics) treats an empty apiKey as "keep - // existing". `list-models` receives a full record envelope rather than a - // patch, so replicate the fallback client-side to avoid hitting an - // unauthenticated upstream. Applies only when the operator didn't type - // anything AND (for custom) the auth style still expects a key. - if (!isCreate.value && config.apiKey === undefined) { - if (draft.value.kind === 'custom' && draft.value.config.authStyle !== 'none' && draft.value.config.apiKey) { - config.apiKey = draft.value.config.apiKey; - } else if (draft.value.kind === 'ollama' && draft.value.config.apiKey) { - config.apiKey = draft.value.config.apiKey; - } - } + 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 } }), @@ -319,15 +302,6 @@ const buildAzureConfig = () => { 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 @@ -336,7 +310,7 @@ const buildOllamaConfig = () => { 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 === 'ollama') return buildOllamaConfig(ollamaDraft.value); return draft.value.config; }; diff --git a/apps/web/src/components/upstream-edit/customConfig.ts b/apps/web/src/components/upstream-edit/customConfig.ts index a339eb0fa..c50cb606f 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,39 @@ 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 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; +}; From b106ff42adad08fb4efdfb691de3aff6f69e0d3c Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 16 Jul 2026 13:45:59 +0800 Subject: [PATCH 4/8] refactor(dashboard): extract applyListModelsResult for the fetch-result dispatch listDraftModels and fetchUpstreamModels both branched on draft.kind to route the response into fetchedRaw (custom) or upstreamModels (else). Fold the two identical tails into one closure so a future kind or slot change only lives in one place. --- .../upstream-edit/UpstreamEditPage.vue | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue index 853701a1a..77a16a867 100644 --- a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue +++ b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue @@ -175,6 +175,14 @@ const customAutoModelsFromDraft = computed(() => fetchedR // already-projected `UpstreamModelConfig`. type ListModelsResult = { data: UpstreamModelConfig[] } | { data: CustomRawModel[] }; +// Route the response into the ref that matches the current kind — custom +// keeps a raw slot so the dashboard can retranslate through the draft's +// endpoints; every other kind lands projected rows. +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; @@ -190,11 +198,7 @@ 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 { - upstreamModels.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 @@ -262,11 +266,7 @@ 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[]; - } + applyListModelsResult(data.data); }; // Prime on mount so ModelsPanel renders populated; the operator-driven From 14f73788e04e6ed41e99d8fcf31c9a94ee1f5f4d Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 16 Jul 2026 14:23:43 +0800 Subject: [PATCH 5/8] docs(dashboard): peer-conformance sweep in upstream-edit panels - drop stale 'operator-driven refresh' caller reference in fetchUpstreamModels - align OllamaConfigPanel top comment + fetchStatus JSDoc with sibling panels --- .../src/components/upstream-edit/OllamaConfigPanel.vue | 7 +------ .../web/src/components/upstream-edit/UpstreamEditPage.vue | 8 ++++---- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/upstream-edit/OllamaConfigPanel.vue b/apps/web/src/components/upstream-edit/OllamaConfigPanel.vue index 88d48dca6..a01d39061 100644 --- a/apps/web/src/components/upstream-edit/OllamaConfigPanel.vue +++ b/apps/web/src/components/upstream-edit/OllamaConfigPanel.vue @@ -3,11 +3,6 @@ // optional bearer token. The catalog is always live-fetched from /api/tags + // /api/show — no toggle, no path overrides, no auth-style choice. The // model-overrides list lives in a separate panel. -// -// The Fetch button re-runs the live catalog fetch against the in-flight form -// config (baseUrl / apiKey), so the operator can preview the resolved list -// before saving on both create and edit. In edit mode an untyped apiKey -// falls back to the stored secret (handled by the parent). import type { OllamaDraft } from './customConfig.ts'; import SecretInput from '../shared/SecretInput.vue'; @@ -20,7 +15,7 @@ defineProps<{ editMode: boolean; fetchLoading: boolean; fetchError: string | null; - /** Wall-clock summary of the last draft fetch, e.g. "35 returned · 1m ago". */ + /** Wall-clock summary of the last fetch, e.g. "35 returned · 1m ago". */ fetchStatus: string | null; }>(); diff --git a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue index 77a16a867..620bd9079 100644 --- a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue +++ b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue @@ -254,10 +254,10 @@ const hasCredentialForFetch = computed(() => { // 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). +// UpstreamModelConfig rows and lands in `upstreamModels`. 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. Surfaces the error on `upstreamModelsError` on failure. const fetchUpstreamModels = async () => { if (draft.value.kind === 'azure') return; if (!hasCredentialForFetch.value) return; From 25c210aef2818e87b92fc81c9c00cd19b9386467 Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 16 Jul 2026 13:59:14 +0800 Subject: [PATCH 6/8] chore(cleanup): trim redundant comments in upstream edit page Three comment-only edits on UpstreamEditPage.vue after the applyListModelsResult extract-refactor: - Drop the comment above applyListModelsResult: the routing rationale is already carried by the ListModelsResult type comment directly above the helper and by the autoForActive comment further down. - Trim the fetchUpstreamModels comment: drop the custom-vs-projected routing paragraph (the extracted helper now owns that routing) and the trailing "surfaces error on upstreamModelsError" line (pure restatement of the adjacent assignment). - Shrink the fetchedRaw declaration comment to a 3-line role hint pointing at customAutoModelsFromDraft; the why-two-refs story stays discoverable via the autoForActive comment. --- .../upstream-edit/UpstreamEditPage.vue | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue index 620bd9079..84d952523 100644 --- a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue +++ b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue @@ -131,14 +131,9 @@ const modelPrefixInvalid = ref(false); const upstreamModels = ref([]); const upstreamModelsError = ref(null); -// Draft preview state for the inline "Fetch" button on the Custom and Ollama -// panels: `POST /api/upstreams/list-models` returns the in-flight config's -// catalog so rows can be picked before saving (create) or before persisting -// edits (edit). `fetchedRaw` carries the Custom raw rows (translated through -// the draft's endpoints by `customAutoModelsFromDraft`); Ollama rows land in -// `upstreamModels` alongside the mount-time result — the two call sites -// produce the same UpstreamModelConfig shape, so a single slot keeps the -// display path uniform across create and edit. +// `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 fetchLoading = ref(false); const fetchError = ref(null); @@ -175,9 +170,6 @@ const customAutoModelsFromDraft = computed(() => fetchedR // already-projected `UpstreamModelConfig`. type ListModelsResult = { data: UpstreamModelConfig[] } | { data: CustomRawModel[] }; -// Route the response into the ref that matches the current kind — custom -// keeps a raw slot so the dashboard can retranslate through the draft's -// endpoints; every other kind lands projected rows. const applyListModelsResult = (data: ListModelsResult['data']): void => { if (draft.value.kind === 'custom') fetchedRaw.value = data as CustomRawModel[]; else upstreamModels.value = data as UpstreamModelConfig[]; @@ -250,14 +242,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`. 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. Surfaces the error on `upstreamModelsError` on failure. +// 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; From 98ddb26c0d4af8732f282d190083032775351d91 Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 16 Jul 2026 14:05:31 +0800 Subject: [PATCH 7/8] refactor(dashboard): move buildAzureConfig to customConfig.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 1 moved buildOllamaConfig out of UpstreamEditPage.vue into the wire-shape module next to buildCustomConfigCore; Azure's counterpart stayed local. Move it too so the two-of-three post-Round-1 split collapses onto one pattern — every editable-provider save-shape projection lives in customConfig.ts, UpstreamEditPage only dispatches. --- .../components/upstream-edit/UpstreamEditPage.vue | 12 ++---------- .../web/src/components/upstream-edit/customConfig.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue index 84d952523..bf6df7985 100644 --- a/apps/web/src/components/upstream-edit/UpstreamEditPage.vue +++ b/apps/web/src/components/upstream-edit/UpstreamEditPage.vue @@ -13,6 +13,7 @@ import { blankAzureDraft, blankCustomDraft, blankOllamaDraft, + buildAzureConfig, buildCustomConfigCore, buildListModelsPreviewConfig, buildOllamaConfig, @@ -280,15 +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; -}; - // 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 @@ -296,7 +288,7 @@ const buildAzureConfig = () => { // 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 === 'azure') return buildAzureConfig(azureDraft.value); if (draft.value.kind === 'ollama') return buildOllamaConfig(ollamaDraft.value); return draft.value.config; }; diff --git a/apps/web/src/components/upstream-edit/customConfig.ts b/apps/web/src/components/upstream-edit/customConfig.ts index c50cb606f..963799f86 100644 --- a/apps/web/src/components/upstream-edit/customConfig.ts +++ b/apps/web/src/components/upstream-edit/customConfig.ts @@ -136,6 +136,17 @@ export const buildOllamaConfig = (draft: OllamaDraft): Record = 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 From e68e9a1e1b7f2a11b244e1455b44df36ec670b84 Mon Sep 17 00:00:00 2001 From: yyyr Date: Thu, 16 Jul 2026 14:18:44 +0800 Subject: [PATCH 8/8] docs(gateway): drop caller names from serializeForResponse comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convention: comments never reference callers. Rewrite the projection comment to explain the baseSerialize parameter caller-agnostically — same WHY, no enumeration of consumer sites. --- packages/gateway/src/control-plane/upstreams/routes.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/gateway/src/control-plane/upstreams/routes.ts b/packages/gateway/src/control-plane/upstreams/routes.ts index 6407b0af0..efc048a5f 100644 --- a/packages/gateway/src/control-plane/upstreams/routes.ts +++ b/packages/gateway/src/control-plane/upstreams/routes.ts @@ -85,10 +85,9 @@ const codexQuotaForResponse = async (record: UpstreamRecord): Promise SerializedUpstreamRecord = upstreamRecordToJson,