Repository: ollama/ollama
Pull request: app: fix false "out of date" model warnings

file on disk) against the registry's Ollama-Content-Digest header.
These never matched because PullModel re-serializes the manifest JSON
before writing, producing different bytes than the registry's original.

The fallback comparison (local modified_at vs upstream push time) was
also broken: the generated TypeScript Time class discards the actual
timestamp value, so Date parsing always produced NaN.

Fix by moving the staleness comparison server-side where we have
reliable access to both the local manifest file mtime and the upstream
push time. The /api/v1/model/upstream endpoint now returns a simple
`stale` boolean instead of raw digests for the frontend to compare.

Also adds User-Agent to the CORS allowed headers for dev mode.

Changed files:
filename: app/ui/app/codegen/gotypes.gen.ts
patch: @@ -550,14 +550,12 @@ export class Error {
     }
 }
 export class ModelUpstreamResponse {
-    digest?: string;
-    pushTime: number;
+    stale: boolean;
     error?: string;
 
     constructor(source: any = {}) {
         if ('string' === typeof source) source = JSON.parse(source);
-        this.digest = source["digest"];
-        this.pushTime = source["pushTime"];
+        this.stale = source["stale"];
         this.error = source["error"];
     }
 }

filename: app/ui/app/src/api.ts
patch: @@ -161,7 +161,7 @@ export async function getModels(query?: string): Promise<Model[]> {
       // Add query if it's in the registry and not already in the list
       if (!exactMatch) {
         const result = await getModelUpstreamInfo(new Model({ model: query }));
-        const existsUpstream = !!result.digest && !result.error;
+        const existsUpstream = result.exists;
         if (existsUpstream) {
           filteredModels.push(new Model({ model: query }));
         }
@@ -339,7 +339,7 @@ export async function deleteChat(chatId: string): Promise<void> {
 // Get upstream information for model staleness checking
 export async function getModelUpstreamInfo(
   model: Model,
-): Promise<{ digest?: string; pushTime: number; error?: string }> {
+): Promise<{ stale: boolean; exists: boolean; error?: string }> {
   try {
     const response = await fetch(`${API_BASE}/api/v1/model/upstream`, {
       method: "POST",
@@ -353,22 +353,22 @@ export async function getModelUpstreamInfo(
 
     if (!response.ok) {
       console.warn(
-        `Failed to check upstream digest for ${model.model}: ${response.status}`,
+        `Failed to check upstream for ${model.model}: ${response.status}`,
       );
-      return { pushTime: 0 };
+      return { stale: false, exists: false };
     }
 
     const data = await response.json();
 
     if (data.error) {
-      console.warn(`Upstream digest check: ${data.error}`);
-      return { error: data.error, pushTime: 0 };
+      console.warn(`Upstream check: ${data.error}`);
+      return { stale: false, exists: false, error: data.error };
     }
 
-    return { digest: data.digest, pushTime: data.pushTime || 0 };
+    return { stale: !!data.stale, exists: true };
   } catch (error) {
     console.warn(`Error checking model staleness:`, error);
-    return { pushTime: 0 };
+    return { stale: false, exists: false };
   }
 }
 

filename: app/ui/app/src/components/ModelPicker.tsx
patch: @@ -61,24 +61,7 @@ export const ModelPicker = forwardRef<
     try {
       const upstreamInfo = await getModelUpstreamInfo(model);
 
-      // Compare local digest with upstream digest
-      let isStale =
-        model.digest &&
-        upstreamInfo.digest &&
-        model.digest !== upstreamInfo.digest;
-
-      // If the model has a modified time and upstream has a push time,
-      // check if the model was modified after the push time - if so, it's not stale
-      if (isStale && model.modified_at && upstreamInfo.pushTime > 0) {
-        const modifiedAtTime =
-          new Date(model.modified_at as string | number | Date).getTime() /
-          1000;
-        if (modifiedAtTime > upstreamInfo.pushTime) {
-          isStale = false;
-        }
-      }
-
-      if (isStale) {
+      if (upstreamInfo.stale) {
         const currentStaleModels =
           queryClient.getQueryData<Map<string, boolean>>(["staleModels"]) ||
           new Map();

filename: app/ui/responses/types.go
patch: @@ -133,9 +133,8 @@ type Error struct {
 }
 
 type ModelUpstreamResponse struct {
-	Digest   string `json:"digest,omitempty"`
-	PushTime int64  `json:"pushTime"`
-	Error    string `json:"error,omitempty"`
+	Stale bool   `json:"stale"`
+	Error string `json:"error,omitempty"`
 }
 
 // Serializable data for the browser state

filename: app/ui/ui.go
patch: @@ -32,6 +32,7 @@ import (
 	"github.com/ollama/ollama/app/version"
 	ollamaAuth "github.com/ollama/ollama/auth"
 	"github.com/ollama/ollama/envconfig"
+	"github.com/ollama/ollama/manifest"
 	"github.com/ollama/ollama/types/model"
 	_ "github.com/tkrajina/typescriptify-golang-structs/typescriptify"
 )
@@ -193,7 +194,7 @@ func (s *Server) Handler() http.Handler {
 			if CORS() {
 				w.Header().Set("Access-Control-Allow-Origin", "*")
 				w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
-				w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
+				w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, User-Agent, Accept, X-Requested-With")
 				w.Header().Set("Access-Control-Allow-Credentials", "true")
 
 				// Handle preflight requests
@@ -318,7 +319,7 @@ func (s *Server) handleError(w http.ResponseWriter, e error) {
 	if CORS() {
 		w.Header().Set("Access-Control-Allow-Origin", "*")
 		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
-		w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
+		w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, User-Agent, Accept, X-Requested-With")
 		w.Header().Set("Access-Control-Allow-Credentials", "true")
 	}
 
@@ -1572,9 +1573,18 @@ func (s *Server) modelUpstream(w http.ResponseWriter, r *http.Request) error {
 		return json.NewEncoder(w).Encode(response)
 	}
 
+	n := model.ParseName(req.Model)
+	stale := true
+	if m, err := manifest.ParseNamedManifest(n); err == nil {
+		if m.Digest() == digest {
+			stale = false
+		} else if pushTime > 0 && m.FileInfo().ModTime().Unix() >= pushTime {
+			stale = false
+		}
+	}
+
 	response := responses.ModelUpstreamResponse{
-		Digest:   digest,
-		PushTime: pushTime,
+		Stale: stale,
 	}
 
 	w.Header().Set("Content-Type", "application/json")