Refresh AITURK model discovery automatically in desktop beta 4

This commit is contained in:
2026-09-06 01:37:27 +03:00
parent aa498f4cbb
commit 237d1e2060
8 changed files with 111 additions and 10 deletions
+4 -1
View File
@@ -2,7 +2,7 @@
TurkServis'in Hermes Agent tabanlı masaüstü geliştirme ortamı. TurkServis'in Hermes Agent tabanlı masaüstü geliştirme ortamı.
- Ürün sürümü: **1.0.0-beta.1**, ilk paket hedefi Windows x64. - Ürün sürümü: **1.0.0-beta.4**, ilk paket hedefi Windows x64.
- Varsayılan dil Türkçe; ayarlardan diğer dillere geçilebilir. - Varsayılan dil Türkçe; ayarlardan diğer dillere geçilebilir.
- İlk kurulum, sohbet, dosyalar, terminal ve temel ayarlar Türkçeleştirilmiştir. - İlk kurulum, sohbet, dosyalar, terminal ve temel ayarlar Türkçeleştirilmiştir.
Henüz çevrilmeyen gelişmiş ekranlar ve üçüncü taraf eklentiler İngilizceye döner. Henüz çevrilmeyen gelişmiş ekranlar ve üçüncü taraf eklentiler İngilizceye döner.
@@ -10,6 +10,9 @@ TurkServis'in Hermes Agent tabanlı masaüstü geliştirme ortamı.
- TurkServis bağlantısı `https://ai.turkservis.online/v1` üzerinden kullanıcının - TurkServis bağlantısı `https://ai.turkservis.online/v1` üzerinden kullanıcının
kendi API anahtarını doğrular ve hesabına açık modelleri dinamik olarak getirir. kendi API anahtarını doğrular ve hesabına açık modelleri dinamik olarak getirir.
Kaynakta veya kurulum paketinde müşteri API anahtarı bulunmaz. Kaynakta veya kurulum paketinde müşteri API anahtarı bulunmaz.
- AITURK model kataloğu arka planda otomatik yenilenir. Açık model listeleri
dakikada bir sunucuyu kontrol eder; 9Router ve OmniRoute'ta yeni yayımlanan
kayıtlar elle liste düzenlemeden görünür. Admin engeli sunucuda uygulanır.
- Bu bağlantı model sağlayıcısıdır; yerel ajan ve dosya araçları bilgisayarda - Bu bağlantı model sağlayıcısıdır; yerel ajan ve dosya araçları bilgisayarda
çalışır. Uzak Hermes gateway bağlantısı farklı bir özelliktir. çalışır. Uzak Hermes gateway bağlantısı farklı bir özelliktir.
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "aiturk-ide", "name": "aiturk-ide",
"productName": "AITURK IDE", "productName": "AITURK IDE",
"private": true, "private": true,
"version": "1.0.0-beta.3", "version": "1.0.0-beta.4",
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
"author": "AITURK / TurkServis", "author": "AITURK / TurkServis",
"repository": { "repository": {
@@ -103,7 +103,7 @@ describe('the catalog owns model curation', () => {
renderMenu() renderMenu()
await screen.findByText(/Gemini 2\.5 Flash/i) await screen.findByText(/Gemini 2\.5 Flash/i)
const input = screen.getByRole('textbox', { name: 'Search models' }) const input = screen.getByRole('textbox', { name: /Search models|Model ara/ })
fireEvent.change(input, { target: { value: 'gemini-3.1' } }) fireEvent.change(input, { target: { value: 'gemini-3.1' } })
@@ -116,7 +116,7 @@ describe('the catalog owns model curation', () => {
renderMenu() renderMenu()
await screen.findByText(/Gemini 3\.1 Pro/i) await screen.findByText(/Gemini 3\.1 Pro/i)
fireEvent.click(screen.getByText('Edit models…')) fireEvent.click(screen.getByText(/Edit models…|Modelleri düzenle…/))
expect($modelVisibilityOpen.get()).toBe(true) expect($modelVisibilityOpen.get()).toBe(true)
}) })
+25 -1
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { QueryObserver, focusManager } from '@tanstack/react-query'
import { invalidateProfileScopedQueries, queryClient } from './query-client' import { invalidateProfileScopedQueries, queryClient } from './query-client'
@@ -11,6 +12,29 @@ describe('invalidateProfileScopedQueries', () => {
queryClient.clear() queryClient.clear()
}) })
it('refreshes an observed model list without reopening the picker', async () => {
vi.useFakeTimers()
focusManager.setFocused(true)
let models = ['old-model']
const observer = new QueryObserver(queryClient, {
queryKey: ['model-options', 'selected-profile'],
queryFn: async () => models
})
const unsubscribe = observer.subscribe(() => {})
try {
await observer.refetch()
expect(observer.getCurrentResult().data).toEqual(['old-model'])
models = ['new-model', 'new-combo']
await vi.advanceTimersByTimeAsync(60_000)
expect(observer.getCurrentResult().data).toEqual(models)
} finally {
unsubscribe()
queryClient.clear()
focusManager.setFocused(undefined)
vi.useRealTimers()
}
})
it('invalidates profile-scoped caches and leaves account/global caches intact', () => { it('invalidates profile-scoped caches and leaves account/global caches intact', () => {
const profileScoped = [ const profileScoped = [
['hermes-config-record'], ['hermes-config-record'],
+6
View File
@@ -12,6 +12,12 @@ export const queryClient = new QueryClient({
} }
}) })
queryClient.setQueryDefaults(['model-options'], {
staleTime: 30_000,
refetchInterval: 60_000,
refetchOnWindowFocus: true
})
// Curried, setState-shaped cache writer for optimistic write-through: keeps // Curried, setState-shaped cache writer for optimistic write-through: keeps
// mutation sites terse (`setX(next)` or `setX(prev => …)`) over one query key. // mutation sites terse (`setX(next)` or `setX(prev => …)`) over one query key.
export const writeCache = export const writeCache =
+17 -4
View File
@@ -8,6 +8,7 @@ Add, remove, or reorder entries here — both `hermes setup` and
from __future__ import annotations from __future__ import annotations
import copy import copy
import contextvars
import json import json
import http.client import http.client
import logging import logging
@@ -4809,10 +4810,11 @@ def _spawn_swr_refresh(cache_key: str, refresh_fn=None) -> None:
``PROVIDER_REGISTRY`` slug and refreshed via :func:`provider_model_ids` ``PROVIDER_REGISTRY`` slug and refreshed via :func:`provider_model_ids`
(the original behavior). (the original behavior).
""" """
refresh_identity = (str(_provider_models_cache_path()), cache_key)
with _swr_refresh_lock: with _swr_refresh_lock:
if cache_key in _swr_refresh_inflight: if refresh_identity in _swr_refresh_inflight:
return return
_swr_refresh_inflight.add(cache_key) _swr_refresh_inflight.add(refresh_identity)
def _default_refresh(): def _default_refresh():
live = provider_model_ids(cache_key, force_refresh=True) live = provider_model_ids(cache_key, force_refresh=True)
@@ -4847,10 +4849,10 @@ def _spawn_swr_refresh(cache_key: str, refresh_fn=None) -> None:
logger.debug("SWR refresh failed for %s", cache_key, exc_info=True) logger.debug("SWR refresh failed for %s", cache_key, exc_info=True)
finally: finally:
with _swr_refresh_lock: with _swr_refresh_lock:
_swr_refresh_inflight.discard(cache_key) _swr_refresh_inflight.discard(refresh_identity)
threading.Thread( threading.Thread(
target=_refresh, daemon=True, name=f"model-cache-swr-{cache_key}" target=contextvars.copy_context().run, args=(_refresh,), daemon=True, name=f"model-cache-swr-{cache_key}"
).start() ).start()
@@ -6855,6 +6857,17 @@ def cached_fetch_api_models(
entry = cache.get(cache_key) entry = cache.get(cache_key)
now = time.time() now = time.time()
if normalized_url == "https://ai.turkservis.online/v1":
ttl_seconds = min(ttl_seconds, 30)
if cache_only and (not _cache_entry_valid(entry, fp) or now - entry["at"] >= ttl_seconds):
def _refresh_aiturk():
live = fetch_api_models(api_key, base_url, timeout=timeout,
api_mode=api_mode, headers=headers)
if live is None:
return None
return {"fp": fp, "at": time.time(), "models": list(live)}
_spawn_swr_refresh(cache_key, _refresh_aiturk)
if cache_only: if cache_only:
# Same trust window as the stale-while-revalidate tier below, minus # Same trust window as the stale-while-revalidate tier below, minus
# the revalidation: an entry this side of the bound is good enough to # the revalidation: an entry this side of the bound is good enough to
+1 -1
View File
@@ -65,7 +65,7 @@
}, },
"apps/desktop": { "apps/desktop": {
"name": "aiturk-ide", "name": "aiturk-ide",
"version": "1.0.0-beta.3", "version": "1.0.0-beta.4",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@assistant-ui/core": "0.2.23", "@assistant-ui/core": "0.2.23",
@@ -0,0 +1,55 @@
"""An open AITURK picker discovers changes without an explicit refresh."""
import threading
import time
from hermes_cli import models
def test_cache_only_picker_refreshes_stale_aiturk_catalog(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
url = "https://ai.turkservis.online/v1"
key = "test-only"
fp = models._custom_endpoint_fingerprint(key, None, None)
models._save_provider_models_cache({"custom:" + url: {
"fp": fp, "at": time.time() - 90, "models": ["old"]}})
started = threading.Event()
release = threading.Event()
def fetch(*args, **kwargs):
started.set()
assert release.wait(5)
return ["new-model", "new-combo"]
monkeypatch.setattr(models, "fetch_api_models", fetch)
try:
assert models.cached_fetch_api_models(key, url, cache_only=True) == ["old"]
assert started.wait(5)
finally:
release.set()
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
result = models.cached_fetch_api_models(key, url, cache_only=True)
if result == ["new-model", "new-combo"]:
break
time.sleep(0.02)
assert result == ["new-model", "new-combo"]
def test_background_refresh_preserves_context_scope(tmp_path, monkeypatch):
import contextvars
scope = contextvars.ContextVar("test_catalog_scope", default="wrong")
seen = []
done = threading.Event()
monkeypatch.setattr(models, "_provider_models_cache_path", lambda: tmp_path / scope.get())
monkeypatch.setattr(models, "_load_provider_models_cache", lambda: {})
def save(cache):
seen.append((scope.get(), cache))
done.set()
monkeypatch.setattr(models, "_save_provider_models_cache", save)
token = scope.set("selected-profile")
try:
models._spawn_swr_refresh("scope-test", lambda: {"models": [scope.get()]})
finally:
scope.reset(token)
assert done.wait(5)
assert seen == [("selected-profile", {"scope-test": {"models": ["selected-profile"]}})]