Skip to content
3 changes: 1 addition & 2 deletions apps/web/src/components/upstream-edit/CustomConfigPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ const setAuthStyle = (style: CustomAuthStyle) => {
<div>
<div class="mb-2 flex items-baseline justify-between gap-3">
<p class="text-xs font-medium text-gray-500">Fetch <code class="font-mono">/models</code></p>
<p v-if="fetchStatus && !editMode" class="text-[11px] text-gray-500">{{ fetchStatus }}</p>
<p v-if="fetchStatus" class="text-[11px] text-gray-500">{{ fetchStatus }}</p>
</div>
<div class="flex items-center gap-2">
<Switch
Expand All @@ -131,7 +131,6 @@ const setAuthStyle = (style: CustomAuthStyle) => {
@update:model-value="v => draft = { ...draft, modelsFetch: { ...draft.modelsFetch, endpoint: v } }"
/>
<Button
v-if="!editMode"
variant="secondary"
size="sm"
:loading="fetchLoading"
Expand Down
31 changes: 12 additions & 19 deletions apps/web/src/components/upstream-edit/ModelsCacheStatus.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,13 @@ import relativeTime from 'dayjs/plugin/relativeTime';
import { computed, ref } from 'vue';

import type { UpstreamRecord } from '../../api/types.ts';
import { Button } from '@floway-dev/ui';

dayjs.extend(relativeTime);

const props = defineProps<{
modelsCache: UpstreamRecord['modelsCache'];
refreshing: boolean;
}>();

defineEmits<{ refresh: [] }>();

const now = useNow({ interval: 30_000 });
const fetchedLabel = computed(() => props.modelsCache.fetchedAt === null
? 'never'
Expand All @@ -28,21 +24,18 @@ const errorOpen = ref(false);
</script>

<template>
<div class="flex flex-wrap items-center gap-3">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs">
<span class="text-gray-300">last fetched <span class="text-white">{{ fetchedLabel }}</span></span>
<span
v-if="modelsCache.lastError"
class="cursor-pointer text-accent-rose hover:underline"
@click="errorOpen = !errorOpen"
>last error {{ errorAtLabel }} ({{ errorOpen ? 'hide' : 'show' }})</span>
</div>
<p
v-if="modelsCache.lastError && errorOpen"
class="rounded-md border border-accent-rose/40 bg-accent-rose/10 px-3 py-2 font-mono text-[11px] text-accent-rose"
>{{ modelsCache.lastError.message }}</p>
<div class="flex min-w-0 flex-col gap-1">
<div class="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs">
<span class="text-gray-300">last fetched <span class="text-white">{{ fetchedLabel }}</span></span>
<span
v-if="modelsCache.lastError"
class="cursor-pointer text-accent-rose hover:underline"
@click="errorOpen = !errorOpen"
>last error {{ errorAtLabel }} ({{ errorOpen ? 'hide' : 'show' }})</span>
</div>
<Button variant="secondary" size="sm" :loading="refreshing" @click="$emit('refresh')">Refresh now</Button>
<p
v-if="modelsCache.lastError && errorOpen"
class="rounded-md border border-accent-rose/40 bg-accent-rose/10 px-3 py-2 font-mono text-[11px] text-accent-rose"
>{{ modelsCache.lastError.message }}</p>
</div>
</template>
8 changes: 2 additions & 6 deletions apps/web/src/components/upstream-edit/OllamaConfigPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +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.
//
// In create mode the panel exposes a Fetch button so the operator can preview
// the upstream's resolved catalog before saving; on edit mode the cache-status
// panel above already drives a force-refresh.

import type { OllamaDraft } from './customConfig.ts';
import SecretInput from '../shared/SecretInput.vue';
Expand All @@ -19,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;
}>();

Expand Down Expand Up @@ -56,7 +52,7 @@ const emit = defineEmits<{ 'fetch-models': [] }>();
</p>
</div>

<div v-if="!editMode">
<div>
<div class="flex flex-wrap items-center justify-between gap-x-3 gap-y-2">
<p class="text-xs font-medium text-gray-500">Fetch models</p>
<div class="flex items-center gap-3">
Expand Down
11 changes: 3 additions & 8 deletions apps/web/src/components/upstream-edit/UpstreamConfigPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,16 @@ 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;
}>();

defineEmits<{
'fetch-models': [];
'refresh-cache': [];
patched: [patch: { config?: unknown; state?: unknown }];
'save-and-open-edit': [];
error: [message: string];
Expand Down Expand Up @@ -208,11 +207,7 @@ onBeforeUnmount(() => floorObserver?.disconnect());

<section v-if="modelsCache" class="shrink-0">
<p class="mb-2 text-[10px] font-semibold uppercase tracking-wider text-gray-500">Models Cache</p>
<ModelsCacheStatus
:models-cache="modelsCache"
:refreshing="refreshing"
@refresh="$emit('refresh-cache')"
/>
<ModelsCacheStatus :models-cache="modelsCache" />
</section>

<section class="shrink-0">
Expand Down
119 changes: 36 additions & 83 deletions apps/web/src/components/upstream-edit/UpstreamEditPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import {
blankAzureDraft,
blankCustomDraft,
blankOllamaDraft,
buildAzureConfig,
buildCustomConfigCore,
buildListModelsPreviewConfig,
buildOllamaConfig,
type CustomDraft,
type OllamaDraft,
seedPathOverrides,
Expand Down Expand Up @@ -129,15 +132,10 @@ const modelPrefixInvalid = ref(false);
const upstreamModels = ref<UpstreamModelConfig[]>([]);
const upstreamModelsError = ref<string | null>(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<CustomRawModel[]>([]);
const fetchedOllamaModels = ref<UpstreamModelConfig[]>([]);
const fetchLoading = ref(false);
const fetchError = ref<string | null>(null);
const fetchedAtMs = ref<number | null>(null);
Expand Down Expand Up @@ -173,17 +171,17 @@ const customAutoModelsFromDraft = computed<UpstreamModelConfig[]>(() => 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<ListModelsResult>(
() => api.api.upstreams['list-models'].$post({ json: { record: previewRecord } }),
Expand All @@ -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;
}
Expand Down Expand Up @@ -240,14 +243,9 @@ const hasCredentialForFetch = computed<boolean>(() => {

// 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;
Expand All @@ -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);
Expand All @@ -303,33 +281,15 @@ const buildCustomConfig = () => {
return config;
};

const buildAzureConfig = () => {
const config: Record<string, unknown> = {
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<string, unknown> = {
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
// PATCH endpoint only replaces user-owned fields, so the OAuth slice we
// 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;
};

Expand Down Expand Up @@ -459,21 +419,16 @@ const modelsManualForActive = computed<UpstreamModelConfig[]>({
},
});

// 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<UpstreamModelConfig[]>(() => {
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;
});

Expand Down Expand Up @@ -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"
Expand Down
49 changes: 48 additions & 1 deletion apps/web/src/components/upstream-edit/customConfig.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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<string, unknown> => {
const config: Record<string, unknown> = {
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<string, unknown> => {
const config: Record<string, unknown> = {
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<string, unknown> => {
const config: Record<string, unknown> = 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;
};
Loading