
    )j7                    N   U d Z ddlmZ ddlZddlZddlZddlZddlZddl	m
Z
 ddlmZ ddlmZ ddlmZ  ej        e          ZdZd	Zd
ed<   dZdZdZde Zdaded<   daded<   d7dZd8dZd9dZ efd:dZ!d;d#Z"d<d%Z#d=d'Z$d(d)d>d+Z%d?d-Z&d?d.Z'd@d0Z(dAd2Z)dBd5Z*dCd6Z+dS )Du  Remote model catalog fetcher.

The Hermes docs site hosts a JSON manifest of curated models for providers
we want to update without shipping a release (currently OpenRouter and
Nous Portal). This module fetches, validates, and caches that manifest,
falling back to the in-repo hardcoded lists when the network is unavailable.

Pipeline
--------
1. ``get_catalog()`` — returns a parsed manifest dict.
   - Checks in-process cache (invalidated by TTL).
   - Reads disk cache at ``~/.hermes/cache/model_catalog.json``.
   - Fetches the master URL if disk cache is stale or missing.
   - On any fetch failure, keeps using the stale cache (or empty dict).

2. ``get_curated_openrouter_models()`` / ``get_curated_nous_models()`` —
   thin accessors returning the shapes existing callers expect. Each
   falls back to the in-repo hardcoded list on any lookup failure.

Schema (version 1)
------------------
::

    {
      "version": 1,
      "updated_at": "2026-04-25T22:00:00Z",
      "metadata": {...},                # free-form
      "providers": {
        "openrouter": {
          "metadata": {...},            # free-form
          "models": [
            {"id": "vendor/model", "description": "recommended",
             "metadata": {...}}          # free-form, model-level
          ]
        },
        "nous": {...}
      }
    }

Unknown fields are ignored — extra metadata can be added at either level
without bumping ``version``. ``version`` bumps are reserved for
breaking changes (renaming ``providers``, changing ``models`` shape).
    )annotationsN)Path)Any)__version__)atomic_replacezAhttps://hermes-agent.nousresearch.com/docs/api/model-catalog.json)zfhttps://raw.githubusercontent.com/NousResearch/hermes-agent/main/website/static/api/model-catalog.jsontuple[str, ...]DEFAULT_CATALOG_FALLBACK_URLS   g       @zhermes-cli/dict[str, Any] | None_catalog_cache        float_catalog_cache_source_mtimereturndict[str, Any]c                    	 ddl m}   |             pi }n# t          $ r i }Y nw xY w|                    d          }t	          |t
                    si }t          |                    dd                    t          |                    d          pt                    t          |                    d          pt                    t	          |                    d          t
                    r|                    d          ni d	S )
z@Load the ``model_catalog`` config block with defaults filled in.r   )load_configmodel_catalogenabledTurl	ttl_hours	providers)r   r   r   r   )hermes_cli.configr   	Exceptionget
isinstancedictboolstrDEFAULT_CATALOG_URLr   DEFAULT_TTL_HOURS)r   cfgraws      =/home/ubuntu/.hermes/hermes-agent/hermes_cli/model_catalog.py_load_catalog_configr%   ^   s   111111kmm!r    ''/
"
"Cc4    	400113775>>8%899377;//D3DEE-78L8Ld-S-S[SWW[)))Y[	  s    $$r   c                 .    ddl m}   |             dz  dz  S )zHReturn the disk cache path. Import lazily so tests can monkeypatch home.r   get_hermes_homecachezmodel_catalog.json)hermes_constantsr(   r'   s    r$   _cache_pathr+   r   s-    000000?w&)===    r   r   timeoutc                   	 t           j                            | dt          d          }t           j                            ||          5 }t          j        |                                                                          }ddd           n# 1 swxY w Y   n# t           j	        j
        t          t
          j        t          f$ r'}t                              d| |           Y d}~dS d}~wt           $ r'}t                              d| |           Y d}~dS d}~ww xY wt#          |          st                              d|            dS |S )	zGHTTP GET the manifest URL and return a parsed dict, or None on failure.zapplication/json)Acceptz
User-Agent)headers)r-   Nz#model catalog fetch failed (%s): %sz$model catalog fetch errored (%s): %sz,model catalog at %s failed schema validation)urllibrequestRequest_HERMES_USER_AGENTurlopenjsonloadsreaddecodeerrorURLErrorTimeoutErrorJSONDecodeErrorOSErrorloggerinfor   _validate_manifest)r   r-   reqrespdataexcs         r$   _fetch_manifestrF   }   s   n$$,0  % 
 
 ^##C#99 	4T:diikk002233D	4 	4 	4 	4 	4 	4 	4 	4 	4 	4 	4 	4 	4 	4 	4L!<1EwO   93DDDttttt   :CEEEttttt d## BCHHHtKsH   A
B 9BB BB BB ,D	C++D8DDprimary_urlfallback_urlsc                    t          | |          }||S |D ]<}|r|| k    rt          ||          }|t                              d|           |c S =dS )a3  Try ``primary_url`` first, then walk ``fallback_urls``.

    Returns the first manifest that fetches and validates, or None when
    every URL fails. Skips fallback URLs identical to the primary so an
    operator who configured the catalog URL to point at the raw GitHub
    copy doesn't double-fetch.
    Nz3model catalog primary URL failed; using fallback %s)rF   r?   r@   )rG   r-   rH   rD   r   s        r$   _fetch_manifest_with_fallbackrJ      s     ;00D   	c[((sG,,KKMsSSSKKK  4r,   rD   r   r   c                   t          | t                    sdS |                     d          }t          |t                    r|t          k    rdS |                     d          }t          |t                    sdS |                                D ]\  }}t          |t                    rt          |t                    s dS |                    d          }t          |t                    s dS |D ]a}t          |t                    s  dS t          |                    d          t                    r|d                                         s  dS bdS )z=Return True when ``data`` matches the minimum manifest shape.Fversionr   modelsidT)	r   r   r   intSUPPORTED_SCHEMA_VERSIONitemsr   liststrip)rD   rL   r   pnamepblockrM   ms          r$   rA   rA      sZ   dD!! uhhy!!Ggs## w1I'I'I u%%Ii&& u"** 
 
v%%% 	Z-E-E 	55H%%&$'' 	55 	 	Aa&& uuuaeeDkk3// qw}} uuu	
 4r,   #tuple[dict[str, Any] | None, float]c                 f   t                      } 	 |                                 j        }n# t          t          f$ r Y dS w xY w	 t          | d          5 }t          j        |          }ddd           n# 1 swxY w Y   n# t          t          j        f$ r Y dS w xY wt          |          sdS ||fS )z@Return ``(data_or_none, mtime)``. mtime is 0 if file is missing.)Nr   utf-8encodingN)
r+   statst_mtimer>   FileNotFoundErroropenr6   loadr=   rA   )pathmtimefhrD   s       r$   _read_disk_cacherd      s   ==D		$&'   {{$))) 	!R9R==D	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	!T)*   {{d## {%=sB   * ??B A5)B 5A99B <A9=B BBNonec                   t                      }	 |j                            dd           |                    |j        dz             }t          |dd          5 }t          j        | |d           |                    d	           d d d            n# 1 swxY w Y   t          ||           d S # t          $ r&}t                              d
|           Y d }~d S d }~ww xY w)NT)parentsexist_okz.tmpwrY   rZ      )indent
z$model catalog cache write failed: %s)r+   parentmkdirwith_suffixsuffixr_   r6   dumpwriter   r>   r?   r@   )rD   ra   tmprc   rE   s        r$   _write_disk_cachert      s5   ==DA$666t{V344#sW--- 	IdBq))))HHTNNN	 	 	 	 	 	 	 	 	 	 	 	 	 	 	 	sD!!!!! A A A:C@@@@@@@@@As<   AB1 -BB1 BB1 BB1 1
C!;CC!F)force_refreshru   c                   t                      }|d         si S t          d|d         dz            }t                      \  }}t          j                    }|duo||z
  |k     }| st          ||t
          k    r	|rt          S | s
|r||a|a|S t          |d         t                    }|.t          |           t                      \  }}	||a|	a|S |a|a|S ||a|a|S i S )u   Return the parsed model catalog manifest, or an empty dict on failure.

    Callers should treat a missing provider/model as "use the in-repo fallback"
    — never raise from this function so the CLI keeps working offline.
    r   r   r   g      @Nr   )	r%   maxrd   timer   r   rJ   DEFAULT_FETCH_TIMEOUTrt   )
ru   r"   ttl_seconds	disk_data
disk_mtimenow
disk_freshfetchednew_disk_data	new_mtimes
             r$   get_catalogr      sC    
 
 Cy> 	c3{+f455K,..Iz
)++C$&KC*,<+KJ &!555 6   Z I,A"&0# ,CJ8MNNG'"""#3#5#5 y$*N*3'   &)#"&0#Ir,   providerc                d   t                      }|d         sdS |d                             |           }t          |t                    sdS |                    d          }t          |t                    r|                                sdS t          |                                t                    S )zEIf ``model_catalog.providers.<name>.url`` is set, fetch that instead.r   Nr   r   )r%   r   r   r   r   rS   rF   ry   )r   r"   provider_cfgoverride_urls       r$   _fetch_provider_overrider   $  s    

 
 Cy> t{#''11LlD)) t##E**LlC(( 0B0B0D0D t <--//1FGGGr,   c                L   t          |           }|@|                    di                               |           }t          |t                    r|S t	                      }|sdS |                    di                               |           }t          |t                    r|ndS )zHReturn the provider's manifest block, respecting per-provider overrides.Nr   )r   r   r   r   r   )r   overrideblockcatalogs       r$   _get_provider_blockr   5  s    '11H["--11(;;eT"" 	LmmG tKKR((,,X66Eud++5555r,   list[tuple[str, str]] | Nonec                 N   t          d          } | sdS g }|                     dg           D ]v}t          |                    d          pd                                          }|s;t          |                    d          pd          }|                    ||f           w|pdS )zReturn OpenRouter's curated ``[(id, description), ...]`` from the manifest.

    Returns ``None`` when the manifest is unavailable, so callers can fall
    back to their hardcoded list.
    
openrouterNrM   rN    descriptionr   r   r   rS   append)r   outrV   middescs        r$   get_curated_openrouter_modelsr   D  s      --E t!#CYYx$$    !%%++#$$**,, 	155''-2..

C;;$r,   list[str] | Nonec                     t          d          } | sdS g }|                     dg           D ]O}t          |                    d          pd                                          }|r|                    |           P|pdS )z~Return Nous Portal's curated list of model ids from the manifest.

    Returns ``None`` when the manifest is unavailable.
    nousNrM   rN   r   r   )r   r   rV   r   s       r$   get_curated_nous_modelsr   W  s    
  ''E tCYYx$$  !%%++#$$**,, 	JJsOOO;$r,   project_root'Path | str'c                   t          |           dz  dz  dz  dz  }	 t          |d          5 }t          j        |          }ddd           n# 1 swxY w Y   n@# t          t          j        f$ r'}t                              d||           Y d}~d	S d}~ww xY wt          |          st                              d
|           d	S t          |           t                       dS )u  Overwrite the disk cache with the catalog shipped in a local checkout.

    ``hermes update`` pulls the latest repo, so the freshly-pulled
    ``website/static/api/model-catalog.json`` IS the newest catalog — no
    network round-trip needed. Copying it straight over the disk cache keeps
    the model picker current even when the remote manifest fetch is bot-gated
    or the Portal hiccups.

    Reads the shipped manifest, validates it against the schema, and writes it
    to ``~/.hermes/cache/model_catalog.json`` via the same atomic writer the
    network path uses. Returns ``True`` on success, ``False`` if the file is
    missing, malformed, or fails validation (caller should treat a ``False``
    as non-fatal — the network fetch path still applies on the next picker
    open).
    websitestaticapizmodel-catalog.jsonrY   rZ   Nz1model catalog seed from checkout skipped (%s): %sFz@model catalog seed from checkout skipped: invalid manifest at %sT)r   r_   r6   r`   r>   r=   r?   debugrA   rt   reset_cache)r   srcrc   rD   rE   s        r$   seed_cache_from_checkoutr   g  s2     |

y
(8
3e
;>R
RC#((( 	!B9R==D	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	! 	!T)*   H#sSSSuuuuu d## WY\]]]udMMM4s:   A AA AA AA B1BBc                     da dadS )zIClear the in-process cache. Used by tests and ``hermes model --refresh``.Nr   )r   r    r,   r$   r   r     s     N"%r,   )r   r   )r   r   )r   r   r-   r   r   r   )rG   r   r-   r   rH   r   r   r   )rD   r   r   r   )r   rW   )rD   r   r   re   )ru   r   r   r   )r   r   r   r   )r   r   )r   r   )r   r   r   r   )r   re   ),__doc__
__future__r   r6   loggingrx   urllib.errorr1   urllib.requestpathlibr   typingr   
hermes_clir   _HERMES_VERSIONutilsr   	getLogger__name__r?   r    r	   __annotations__r!   ry   rP   r4   r   r   r%   r+   rF   rJ   rA   rd   rt   r   r   r   r   r   r   r   r   r,   r$   <module>r      si  * * *X # " " " " "                        5 5 5 5 5 5            		8	$	$ H 2         4?44 
 )- , , , ,%(  ( ( ( (   (> > > >   : &C    2   4   "
A 
A 
A 
A$ */ 4 4 4 4 4 4nH H H H"6 6 6 6   &       >& & & & & &r,   